From f2bc2f26a9af498fd8ad1f2c9bf72bfb609dcc70 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 11:34:34 -0700 Subject: [PATCH] Remove core protocol dependency [2/2] (#20325) ## Why With the local model layer and app-server routing in place from PR1, this PR moves the active TUI runtime onto app-server notifications. The affected pieces share the same event flow, so the command surface, session state, bottom-pane prompts, chat rendering, history/status views, and tests move together to keep the stacked branch buildable. This PR also removes the obsolete compatibility surface that is no longer used after the migration. The proposed protocol-boundary verifier layer was dropped from the stack; enforcing that final boundary will be simpler once `codex-tui` no longer needs any `codex_protocol` references. This PR is part 2 of a 2-PR stack: 1. Add TUI-owned replacement models and extract app-server event routing. 2. Move the active TUI flow to app-server notifications and delete obsolete adapter code. ## What changed - Rewired app command and session handling to use app-server request and notification shapes. - Moved approval overlays, request-user-input flows, MCP elicitation, realtime events, and review commands onto the app-server-facing model surface. - Updated chat rendering, history cells, status views, multi-agent UI, replay state, and TUI tests to use app-server notifications plus the local models introduced in PR1. - Deleted `codex-rs/tui/src/app/app_server_adapter.rs` and the superseded `chatwidget/tests/background_events.rs` fixture path. ## Verification - `cargo check -p codex-tui --tests` - Top of stack: `cargo test -p codex-tui` --- codex-rs/cli/src/main.rs | 7 +- codex-rs/tui/src/additional_dirs.rs | 29 +- codex-rs/tui/src/app.rs | 207 +- codex-rs/tui/src/app/app_server_adapter.rs | 1714 ------------ codex-rs/tui/src/app/app_server_events.rs | 26 +- codex-rs/tui/src/app/app_server_requests.rs | 156 +- codex-rs/tui/src/app/background_requests.rs | 3 +- codex-rs/tui/src/app/config_persistence.rs | 29 +- codex-rs/tui/src/app/event_dispatch.rs | 15 +- codex-rs/tui/src/app/loaded_threads.rs | 65 +- .../tui/src/app/pending_interactive_replay.rs | 85 +- codex-rs/tui/src/app/session_lifecycle.rs | 8 +- codex-rs/tui/src/app/test_support.rs | 3 +- codex-rs/tui/src/app/tests.rs | 522 ++-- codex-rs/tui/src/app/thread_events.rs | 4 +- codex-rs/tui/src/app/thread_routing.rs | 131 +- codex-rs/tui/src/app/thread_session_state.rs | 64 +- codex-rs/tui/src/app_backtrack.rs | 6 +- codex-rs/tui/src/app_command.rs | 582 +---- codex-rs/tui/src/app_event.rs | 17 +- codex-rs/tui/src/app_event_sender.rs | 46 +- .../src/app_server_approval_conversions.rs | 117 +- codex-rs/tui/src/app_server_session.rs | 258 +- codex-rs/tui/src/auto_review_denials.rs | 8 +- codex-rs/tui/src/bottom_pane/app_link_view.rs | 38 +- .../tui/src/bottom_pane/approval_overlay.rs | 355 ++- .../tui/src/bottom_pane/bottom_pane_view.rs | 6 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 6 +- .../src/bottom_pane/chat_composer_history.rs | 14 +- .../src/bottom_pane/mcp_server_elicitation.rs | 171 +- codex-rs/tui/src/bottom_pane/mod.rs | 32 +- .../src/bottom_pane/request_user_input/mod.rs | 203 +- codex-rs/tui/src/chatwidget.rs | 2303 ++++------------- codex-rs/tui/src/chatwidget/interrupts.rs | 131 +- codex-rs/tui/src/chatwidget/mcp_startup.rs | 30 +- codex-rs/tui/src/chatwidget/realtime.rs | 229 +- codex-rs/tui/src/chatwidget/skills.rs | 39 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 9 +- ...e_final_message_are_rendered_snapshot.snap | 4 +- .../tui/src/chatwidget/status_surfaces.rs | 6 +- codex-rs/tui/src/chatwidget/tests.rs | 106 +- .../tui/src/chatwidget/tests/app_server.rs | 73 +- .../src/chatwidget/tests/approval_requests.rs | 55 +- .../src/chatwidget/tests/background_events.rs | 18 - .../chatwidget/tests/composer_submission.rs | 207 +- .../tui/src/chatwidget/tests/exec_flow.rs | 510 ++-- codex-rs/tui/src/chatwidget/tests/guardian.rs | 325 +-- codex-rs/tui/src/chatwidget/tests/helpers.rs | 856 ++++-- .../src/chatwidget/tests/history_replay.rs | 632 ++--- .../tui/src/chatwidget/tests/mcp_startup.rs | 445 +--- .../tui/src/chatwidget/tests/permissions.rs | 193 +- .../tui/src/chatwidget/tests/plan_mode.rs | 143 +- .../chatwidget/tests/popups_and_settings.rs | 20 +- .../tui/src/chatwidget/tests/review_mode.rs | 434 +--- .../src/chatwidget/tests/slash_commands.rs | 609 +---- .../src/chatwidget/tests/status_and_layout.rs | 951 +++---- .../src/chatwidget/tests/terminal_title.rs | 24 +- codex-rs/tui/src/debug_config.rs | 8 +- codex-rs/tui/src/diff_render.rs | 4 +- codex-rs/tui/src/exec_cell/model.rs | 2 +- codex-rs/tui/src/exec_cell/render.rs | 4 +- codex-rs/tui/src/history_cell.rs | 192 +- codex-rs/tui/src/history_cell/hook_cell.rs | 18 +- codex-rs/tui/src/lib.rs | 495 +--- codex-rs/tui/src/main.rs | 2 +- codex-rs/tui/src/multi_agents.rs | 719 ++--- codex-rs/tui/src/onboarding/auth.rs | 4 +- codex-rs/tui/src/pager_overlay.rs | 6 +- codex-rs/tui/src/permission_compat.rs | 36 +- codex-rs/tui/src/resume_picker.rs | 8 +- codex-rs/tui/src/status/card.rs | 22 +- codex-rs/tui/src/status/rate_limits.rs | 10 +- codex-rs/tui/src/status/tests.rs | 162 +- codex-rs/tui/src/status_indicator_widget.rs | 5 - codex-rs/tui/src/test_support.rs | 34 + codex-rs/tui/src/voice.rs | 19 +- 76 files changed, 5078 insertions(+), 9951 deletions(-) delete mode 100644 codex-rs/tui/src/app/app_server_adapter.rs delete mode 100644 codex-rs/tui/src/chatwidget/tests/background_events.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 83790c2c5..7b6e7448d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -533,10 +533,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 4bf66a320..b503b1311 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 e42a4c735..fcc354286 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,18 @@ 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::TokenUsage; use crate::transcript_reflow::TranscriptReflowState; use crate::tui; use crate::tui::TuiEvent; @@ -82,6 +81,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 +92,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 +105,11 @@ 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::RequestId; +use codex_app_server_protocol::RateLimitSnapshot; 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; @@ -126,7 +129,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; @@ -135,19 +137,8 @@ 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_protocol::permissions::FileSystemSandboxKind; use codex_terminal_detection::user_agent; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; @@ -224,48 +215,6 @@ enum ThreadInteractiveRequest { McpServerElicitation(McpServerElicitationFormRequest), } -fn app_server_request_id_to_mcp_request_id( - request_id: &codex_app_server_protocol::RequestId, -) -> codex_protocol::mcp::RequestId { - match request_id { - codex_app_server_protocol::RequestId::String(value) => { - codex_protocol::mcp::RequestId::String(value.clone()) - } - codex_app_server_protocol::RequestId::Integer(value) => { - codex_protocol::mcp::RequestId::Integer(*value) - } - } -} - -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 +240,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)] @@ -378,7 +361,7 @@ fn session_summary( thread_name: Option, rollout_path: Option<&Path>, ) -> Option { - let usage_line = (!token_usage.is_zero()).then(|| FinalOutput::from(token_usage).to_string()); + let usage_line = (!token_usage.is_zero()).then(|| token_usage.to_string()); let thread_id = resumable_thread(thread_id, thread_name, rollout_path).map(|thread| thread.thread_id); let resume_command = @@ -417,86 +400,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, @@ -760,7 +672,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 425975823..000000000 --- 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 { - realtime_session_id: notification.realtime_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_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 05b76a7a1..413e8b8c8 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -7,11 +7,9 @@ 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::app_server_rate_limit_snapshot_to_core; 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::JSONRPCErrorError; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -77,9 +75,8 @@ impl App { 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()), - )); + self.chat_widget + .on_rate_limit_snapshot(Some(notification.rate_limits.clone())); return; } ServerNotification::AccountUpdated(notification) => { @@ -186,23 +183,4 @@ impl App { 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}")) - } } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index 5c2c69385..4b587b0fc 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -1,20 +1,38 @@ 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::McpServerElicitationAction; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::PermissionsRequestApprovalResponse; 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 { @@ -44,7 +62,7 @@ pub(crate) enum ResolvedAppServerRequest { }, McpElicitation { server_name: String, - request_id: McpRequestId, + request_id: AppServerRequestId, }, } @@ -54,7 +72,7 @@ pub(super) struct PendingAppServerRequests { file_change_approvals: HashMap, permissions_approvals: HashMap, user_inputs: HashMap>, - mcp_requests: HashMap, + mcp_requests: HashMap, } impl PendingAppServerRequests { @@ -101,9 +119,9 @@ impl PendingAppServerRequests { } ServerRequest::McpServerElicitationRequest { request_id, params } => { self.mcp_requests.insert( - McpLegacyRequestKey { + McpRequestKey { server_name: params.server_name.clone(), - request_id: app_server_request_id_to_mcp_request_id(request_id), + request_id: request_id.clone(), }, request_id.clone(), ); @@ -141,30 +159,32 @@ 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}") + format!( + "failed to serialize command execution approval response: {err}" + ) })?, }) }) .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 +192,7 @@ impl PendingAppServerRequests { }) }) .transpose()?, - AppCommandView::RequestPermissionsResponse { id, response } => self + AppCommand::RequestPermissionsResponse { id, response } => self .permissions_approvals .remove(id) .map(|request_id| { @@ -191,30 +211,18 @@ impl PendingAppServerRequests { }) }) .transpose()?, - AppCommandView::UserInputAnswer { id, response } => self + AppCommand::UserInputAnswer { id, response } => self .pop_user_input_request_for_turn(id) .map(|pending| { Ok::(AppServerRequestResolution { request_id: pending.request_id, - result: serde_json::to_value( - serde_json::from_value::( - serde_json::to_value(response).map_err(|err| { - format!("failed to encode request_user_input response: {err}") - })?, - ) - .map_err(|err| { - format!( - "failed to decode request_user_input response for app-server: {err}" - ) - })?, - ) - .map_err(|err| { + result: serde_json::to_value(response).map_err(|err| { format!("failed to serialize request_user_input response: {err}") })?, }) }) .transpose()?, - AppCommandView::ResolveElicitation { + AppCommand::ResolveElicitation { server_name, request_id, decision, @@ -222,7 +230,7 @@ impl PendingAppServerRequests { meta, } => self .mcp_requests - .remove(&McpLegacyRequestKey { + .remove(&McpRequestKey { server_name: server_name.to_string(), request_id: request_id.clone(), }) @@ -230,17 +238,7 @@ impl PendingAppServerRequests { Ok::(AppServerRequestResolution { request_id, result: serde_json::to_value(McpServerElicitationRequestResponse { - action: match decision { - codex_protocol::approvals::ElicitationAction::Accept => { - McpServerElicitationAction::Accept - } - codex_protocol::approvals::ElicitationAction::Decline => { - McpServerElicitationAction::Decline - } - codex_protocol::approvals::ElicitationAction::Cancel => { - McpServerElicitationAction::Cancel - } - }, + action: *decision, content: content.clone(), meta: meta.clone(), }) @@ -383,44 +381,25 @@ struct PendingUserInputRequest { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct McpLegacyRequestKey { +struct McpRequestKey { server_name: String, - request_id: McpRequestId, -} - -fn app_server_request_id_to_mcp_request_id(request_id: &AppServerRequestId) -> McpRequestId { - match request_id { - AppServerRequestId::String(value) => McpRequestId::String(value.clone()), - AppServerRequestId::Integer(value) => McpRequestId::Integer(*value), - } -} - -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()) - } - } + request_id: AppServerRequestId, } #[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; + use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::PermissionGrantScope; @@ -431,13 +410,8 @@ mod tests { use codex_app_server_protocol::ToolRequestUserInputAnswer; 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 +448,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"); @@ -586,10 +560,10 @@ mod tests { let user_input = pending .take_resolution(&Op::UserInputAnswer { id: "turn-2".to_string(), - response: codex_protocol::request_user_input::RequestUserInputResponse { + response: ToolRequestUserInputResponse { answers: std::iter::once(( "question".to_string(), - codex_protocol::request_user_input::RequestUserInputAnswer { + ToolRequestUserInputAnswer { answers: vec!["yes".to_string()], }, )) @@ -643,8 +617,8 @@ mod tests { let resolution = pending .take_resolution(&Op::ResolveElicitation { server_name: "example".to_string(), - request_id: McpRequestId::Integer(12), - decision: ElicitationAction::Accept, + request_id: AppServerRequestId::Integer(12), + decision: McpServerElicitationAction::Accept, content: Some(json!({ "answer": "yes" })), meta: Some(json!({ "source": "tui" })), }) @@ -703,7 +677,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 +693,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] @@ -803,7 +771,7 @@ mod tests { pending.resolve_notification(&AppServerRequestId::Integer(12)), Some(ResolvedAppServerRequest::McpElicitation { server_name: "example".to_string(), - request_id: McpRequestId::Integer(12), + request_id: AppServerRequestId::Integer(12), }) ); } @@ -844,7 +812,7 @@ mod tests { }); } - let response = codex_protocol::request_user_input::RequestUserInputResponse { + let response = ToolRequestUserInputResponse { answers: HashMap::new(), }; let first_response = pending diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 45d7247e9..c7864cfef 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -9,6 +9,7 @@ use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveParams; use codex_app_server_protocol::MarketplaceRemoveResponse; +use codex_app_server_protocol::RequestId; use codex_utils_absolute_path::AbsolutePathBuf; impl App { @@ -483,7 +484,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 1310a7152..09dd402cd 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 @@ -493,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,9 +549,6 @@ mod tests { use crate::app::test_support::make_test_app; 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; @@ -634,11 +632,11 @@ mod tests { let next_cwd_tmp = tempdir()?; let next_cwd = next_cwd_tmp.path().to_path_buf(); - app.chat_widget.handle_codex_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: ThreadId::new(), + app.chat_widget + .handle_thread_session(crate::session_state::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(), @@ -648,14 +646,13 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: next_cwd.clone().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()), - }), - }); + }); assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd); assert_eq!(app.config.cwd, original_cwd); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ca17daed7..08c7015ce 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1055,7 +1055,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")] @@ -1068,8 +1068,9 @@ impl App { /*personality*/ None, ), )); - 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(), )); @@ -1317,10 +1318,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 b66536138..c98a54180 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, /*depth*/ 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, /*depth*/ 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, /*depth*/ 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 d3963e3d4..cfcbd7ce9 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; @@ -10,11 +9,11 @@ use std::collections::HashSet; #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ElicitationRequestKey { server_name: String, - request_id: codex_protocol::mcp::RequestId, + request_id: AppServerRequestId, } impl ElicitationRequestKey { - fn new(server_name: String, request_id: codex_protocol::mcp::RequestId) -> Self { + fn new(server_name: String, request_id: AppServerRequestId) -> Self { Self { server_name, request_id, @@ -33,9 +32,9 @@ impl ElicitationRequestKey { // - buffer eviction (`note_evicted_event`) // // We keep both fast lookup sets (for snapshot filtering by call_id/request key) and -// turn-indexed queues/vectors so `TurnComplete`/`TurnAborted` can clear stale prompts tied -// to a turn. `request_user_input` removal is FIFO because the overlay answers queued prompts -// in FIFO order for a shared `turn_id`. +// turn-indexed queues/vectors so turn completion or interruption can clear +// stale prompts tied to a turn. `request_user_input` removal is FIFO because +// the overlay answers queued prompts in FIFO order for a shared `turn_id`. pub(super) struct PendingInteractiveReplayState { exec_approval_call_ids: HashSet, exec_approval_call_ids_by_turn_id: HashMap>, @@ -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(), _ => {} } } @@ -208,10 +207,8 @@ impl PendingInteractiveReplayState { ); } ServerRequest::McpServerElicitationRequest { request_id, params } => { - let key = ElicitationRequestKey::new( - params.server_name.clone(), - app_server_request_id_to_mcp_request_id(request_id), - ); + let key = + ElicitationRequestKey::new(params.server_name.clone(), request_id.clone()); self.elicitation_requests.insert(key.clone()); self.pending_requests_by_request_id.insert( request_id.clone(), @@ -311,7 +308,7 @@ impl PendingInteractiveReplayState { self.elicitation_requests .remove(&ElicitationRequestKey::new( params.server_name.clone(), - app_server_request_id_to_mcp_request_id(request_id), + request_id.clone(), )); } ServerRequest::ToolRequestUserInput { params, .. } => { @@ -366,7 +363,7 @@ impl PendingInteractiveReplayState { .elicitation_requests .contains(&ElicitationRequestKey::new( params.server_name.clone(), - app_server_request_id_to_mcp_request_id(request_id), + request_id.clone(), )), ServerRequest::ToolRequestUserInput { params, .. } => { self.request_user_input_call_ids.contains(¶ms.item_id) @@ -549,10 +546,7 @@ impl PendingInteractiveReplayState { ( PendingInteractiveRequest::Elicitation(key), ServerRequest::McpServerElicitationRequest { request_id, params }, - ) => { - key.server_name == params.server_name - && key.request_id == app_server_request_id_to_mcp_request_id(request_id) - } + ) => key.server_name == params.server_name && key.request_id == *request_id, ( PendingInteractiveRequest::RequestPermissions { turn_id, item_id }, ServerRequest::PermissionsRequestApproval { params, .. }, @@ -566,23 +560,17 @@ impl PendingInteractiveReplayState { } } -fn app_server_request_id_to_mcp_request_id( - request_id: &AppServerRequestId, -) -> codex_protocol::mcp::RequestId { - match request_id { - AppServerRequestId::String(value) => codex_protocol::mcp::RequestId::String(value.clone()), - AppServerRequestId::Integer(value) => codex_protocol::mcp::RequestId::Integer(*value), - } -} - #[cfg(test)] mod tests { use super::super::ThreadBufferedEvent; use super::super::ThreadEventStore; + use crate::app_command::AppCommand as Op; + use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::McpElicitationObjectType; use codex_app_server_protocol::McpElicitationSchema; + use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::RequestId as AppServerRequestId; @@ -591,11 +579,10 @@ mod tests { use codex_app_server_protocol::ServerRequestResolvedNotification; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_app_server_protocol::ToolRequestUserInputResponse; 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; @@ -724,7 +711,7 @@ mod tests { store.note_outbound_op(&Op::UserInputAnswer { id: "turn-1".to_string(), - response: codex_protocol::request_user_input::RequestUserInputResponse { + response: ToolRequestUserInputResponse { answers: HashMap::new(), }, }); @@ -767,7 +754,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: CommandExecutionApprovalDecision::Accept, }); let snapshot = store.snapshot(); @@ -809,7 +796,7 @@ mod tests { store.note_outbound_op(&Op::UserInputAnswer { id: "turn-1".to_string(), - response: codex_protocol::request_user_input::RequestUserInputResponse { + response: ToolRequestUserInputResponse { answers: HashMap::new(), }, }); @@ -833,7 +820,7 @@ mod tests { store.note_outbound_op(&Op::UserInputAnswer { id: "turn-1".to_string(), - response: codex_protocol::request_user_input::RequestUserInputResponse { + response: ToolRequestUserInputResponse { answers: HashMap::new(), }, }); @@ -854,7 +841,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(); @@ -888,13 +875,13 @@ mod tests { #[test] fn thread_event_snapshot_drops_resolved_elicitation_after_outbound_resolution() { let mut store = ThreadEventStore::new(/*capacity*/ 8); - let request_id = codex_protocol::mcp::RequestId::String("request-1".to_string()); + let request_id = AppServerRequestId::String("request-1".to_string()); store.push_request(elicitation_request("server-1", "request-1", "turn-1")); store.note_outbound_op(&Op::ResolveElicitation { server_name: "server-1".to_string(), request_id, - decision: codex_protocol::approvals::ElicitationAction::Accept, + decision: McpServerElicitationAction::Accept, content: None, meta: None, }); @@ -920,7 +907,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: CommandExecutionApprovalDecision::Accept, }); assert_eq!(store.has_pending_thread_approvals(), false); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index c51863bd1..4eded21f5 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 fd88161ca..18f60583e 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 3e5d3f741..64fc2889d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -6,7 +6,6 @@ use super::*; use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; -use crate::app_command::AppCommand; use crate::chatwidget::ChatWidgetInit; use crate::chatwidget::create_initial_user_message; @@ -22,6 +21,8 @@ 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::diff_model::FileChange; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::TerminalResizeReflowMaxRows; @@ -29,6 +30,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; @@ -47,6 +49,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; @@ -60,6 +63,7 @@ use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; use codex_app_server_protocol::UserInput as AppServerUserInput; use codex_app_server_protocol::WarningNotification; use codex_otel::SessionTelemetry; @@ -68,24 +72,11 @@ 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; use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyModifiers; use insta::assert_snapshot; @@ -442,12 +433,12 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm } AppEvent::SubmitThreadOp { thread_id: op_thread_id, - op: AppCommand::UserTurn { items, .. }, + op: Op::UserTurn { items, .. }, } => { assert_eq!(op_thread_id, thread_id); submitted_items = Some(items); } - AppEvent::CodexOp(AppCommand::UserTurn { items, .. }) => { + AppEvent::CodexOp(Op::UserTurn { items, .. }) => { submitted_items = Some(items); } _ => {} @@ -514,8 +505,7 @@ async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()> &Op::GetHistoryEntryRequest { offset: 0, log_id: 1, - } - .into(), + }, ) .await?; @@ -1338,32 +1328,43 @@ async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> { Ok(()) } -#[tokio::test] -async fn open_agent_picker_marks_loaded_threads_open() -> Result<()> { - let mut app = Box::pin(make_test_app()).await; - let mut app_server = Box::pin(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<()> { + const WORKER_THREADS: usize = 1; + const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; - Box::pin(app.open_agent_picker(&mut app_server)).await; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .thread_stack_size(TEST_STACK_SIZE_BYTES) + .enable_all() + .build()?; - assert_eq!( - app.agent_navigation.get(&thread_id), - Some(&AgentPickerThreadEntry { - agent_nickname: None, - agent_role: None, - is_closed: false, - }) - ); - Ok(()) + runtime.block_on(async { + let mut app = Box::pin(make_test_app()).await; + let mut app_server = Box::pin(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)); + + Box::pin(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(()) + }) } #[test] @@ -1575,68 +1576,84 @@ async fn update_memory_settings_persists_and_updates_widget_config() -> Result<( Ok(()) } -#[tokio::test] -async fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> { - let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - // Seed the previous setting so this test exercises the thread-mode update path. - app.config.memories.generate_memories = true; +#[test] +fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> { + const WORKER_THREADS: usize = 1; + const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; - let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; - let started = app_server.start_thread(&app.config).await?; - let thread_id = started.session.thread_id; - app.active_thread_id = Some(thread_id); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .thread_stack_size(TEST_STACK_SIZE_BYTES) + .enable_all() + .build()?; - Box::pin(app.update_memory_settings_with_app_server( - &mut app_server, - /*use_memories*/ true, - /*generate_memories*/ false, - )) - .await; + runtime.block_on(async { + let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + // Seed the previous setting so this test exercises the thread-mode update path. + app.config.memories.generate_memories = true; - let state_db = codex_state::StateRuntime::init( - codex_home.path().to_path_buf(), - app.config.model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); - let memory_mode = state_db - .get_thread_memory_mode(thread_id) + let mut app_server = + Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + app.active_thread_id = Some(thread_id); + + Box::pin(app.update_memory_settings_with_app_server( + &mut app_server, + /*use_memories*/ true, + /*generate_memories*/ false, + )) + .await; + + let state_db = codex_state::StateRuntime::init( + codex_home.path().to_path_buf(), + app.config.model_provider_id.clone(), + ) .await - .expect("thread memory mode should be readable"); - assert_eq!(memory_mode.as_deref(), Some("disabled")); + .expect("state db should initialize"); + let memory_mode = state_db + .get_thread_memory_mode(thread_id) + .await + .expect("thread memory mode should be readable"); + assert_eq!(memory_mode.as_deref(), Some("disabled")); - app_server.shutdown().await?; - Ok(()) + app_server.shutdown().await?; + Ok(()) + }) } #[tokio::test] async fn reset_memories_clears_local_memory_directories() -> Result<()> { - let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - app.config.sqlite_home = codex_home.path().to_path_buf(); + Box::pin(async { + let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite_home = codex_home.path().to_path_buf(); - let memory_root = codex_home.path().join("memories"); - let extensions_root = memory_root.join("extensions"); - std::fs::create_dir_all(memory_root.join("rollout_summaries"))?; - std::fs::create_dir_all(&extensions_root)?; - std::fs::write(memory_root.join("MEMORY.md"), "stale memory\n")?; - std::fs::write( - memory_root.join("rollout_summaries").join("stale.md"), - "stale summary\n", - )?; - std::fs::write(extensions_root.join("stale.txt"), "stale extension\n")?; + let memory_root = codex_home.path().join("memories"); + let extensions_root = memory_root.join("extensions"); + std::fs::create_dir_all(memory_root.join("rollout_summaries"))?; + std::fs::create_dir_all(&extensions_root)?; + std::fs::write(memory_root.join("MEMORY.md"), "stale memory\n")?; + std::fs::write( + memory_root.join("rollout_summaries").join("stale.md"), + "stale summary\n", + )?; + std::fs::write(extensions_root.join("stale.txt"), "stale extension\n")?; - let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut app_server = + Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; - Box::pin(app.reset_memories_with_app_server(&mut app_server)).await; + Box::pin(app.reset_memories_with_app_server(&mut app_server)).await; - assert_eq!(std::fs::read_dir(&memory_root)?.count(), 0); + assert_eq!(std::fs::read_dir(&memory_root)?.count(), 0); - app_server.shutdown().await?; - Ok(()) + app_server.shutdown().await?; + Ok(()) + }) + .await } #[tokio::test] @@ -1661,15 +1678,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!( @@ -1694,7 +1713,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, @@ -1750,7 +1768,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())?; @@ -1771,7 +1789,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!( @@ -1785,7 +1803,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, @@ -1848,7 +1865,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!( @@ -1864,7 +1881,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, @@ -1922,7 +1938,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, @@ -1982,7 +1997,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, @@ -2070,7 +2084,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, @@ -2502,35 +2515,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, ] ); } @@ -2775,35 +2790,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)?), @@ -3241,6 +3235,7 @@ async fn side_thread_snapshot_hides_forked_parent_transcript() { let mut store = ThreadEventStore::new(/*capacity*/ 4); let session = ThreadSessionState { forked_from_id: Some(parent_thread_id), + fork_parent_title: None, ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) }; let parent_turn = test_turn( @@ -3303,6 +3298,7 @@ async fn side_thread_snapshot_skips_session_header_preamble() { let snapshot = ThreadEventSnapshot { session: Some(ThreadSessionState { forked_from_id: Some(parent_thread_id), + fork_parent_title: None, ..test_thread_session(side_thread_id, test_path_buf("/tmp/side")) }), turns: Vec::new(), @@ -3390,30 +3386,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] @@ -3598,9 +3597,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(), @@ -3610,17 +3610,17 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, 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, @@ -4248,7 +4248,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(), ) } @@ -4351,9 +4351,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(), @@ -4363,17 +4364,17 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, 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, @@ -4413,11 +4414,11 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { assert_eq!(user_count(&app.transcript_cells), 2); let base_id = ThreadId::new(); - app.chat_widget.handle_codex_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: base_id, + app.chat_widget + .handle_thread_session(crate::session_state::ThreadSessionState { + thread_id: base_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), @@ -4427,14 +4428,13 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, 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()), - }), - }); + }); app.backtrack.base_id = Some(base_id); app.backtrack.primed = true; @@ -4507,11 +4507,11 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - app.chat_widget.handle_codex_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, + app.chat_widget + .handle_thread_session(crate::session_state::ThreadSessionState { + thread_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), @@ -4521,14 +4521,13 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, 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()), - }), - }); + }); let data_image_url = "data:image/png;base64,abc123".to_string(); app.transcript_cells = vec![Arc::new(UserHistoryCell { @@ -4564,7 +4563,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { assert!(items.iter().any(|item| { matches!( item, - UserInput::Image { image_url } if image_url == &data_image_url + UserInput::Image { url } if url == &data_image_url ) })); } @@ -4749,6 +4748,7 @@ async fn refreshed_snapshot_session_persists_resumed_turns() { )]; let resumed_session = ThreadSessionState { cwd: test_path_buf("/tmp/refreshed").abs(), + instruction_source_paths: Vec::new(), ..initial_session.clone() }; let mut snapshot = ThreadEventSnapshot { @@ -4873,7 +4873,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, @@ -4893,48 +4893,49 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { - let (mut app, mut app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await; + Box::pin(async { + let (mut app, mut app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await; - let thread_id = ThreadId::new(); - let event = SessionConfiguredEvent { - session_id: thread_id, - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - permission_profile: PermissionProfile::read_only(), - active_permission_profile: None, - cwd: test_path_buf("/home/user/project").abs(), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }; + let thread_id = ThreadId::new(); + let event = crate::session_state::ThreadSessionState { + thread_id, + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }; - app.chat_widget.handle_codex_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(event), - }); + app.chat_widget.handle_thread_session(event); - while app_event_rx.try_recv().is_ok() {} - while op_rx.try_recv().is_ok() {} + while app_event_rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} - let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( - app.chat_widget.config_ref(), - )) - .await - .expect("embedded app server"); - Box::pin(app.shutdown_current_thread(&mut app_server)).await; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await + .expect("embedded app server"); + Box::pin(app.shutdown_current_thread(&mut app_server)).await; - assert!( - op_rx.try_recv().is_err(), - "shutdown should not submit Op::Shutdown" - ); + assert!( + op_rx.try_recv().is_err(), + "shutdown should not submit Op::Shutdown" + ); + }) + .await; } #[tokio::test] @@ -4983,39 +4984,45 @@ async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() { #[tokio::test] async fn interrupt_without_active_turn_is_treated_as_handled() { - let mut app = make_test_app().await; - let mut app_server = Box::pin(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()) + Box::pin(async { + let mut app = make_test_app().await; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) .await - .expect("thread/start should succeed"); - let thread_id = started.session.thread_id; - app.enqueue_primary_thread_session(started.session, started.turns) - .await - .expect("primary thread should be registered"); - let op = AppCommand::interrupt(); - - let handled = - Box::pin(app.try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &op)) + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) .await - .expect("interrupt submission should not fail"); + .expect("thread/start should succeed"); + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await + .expect("primary thread should be registered"); + let op = AppCommand::interrupt(); - assert_eq!(handled, true); + let handled = Box::pin(app.try_submit_active_thread_op_via_app_server( + &mut app_server, + thread_id, + &op, + )) + .await + .expect("interrupt submission should not fail"); + + assert_eq!(handled, true); + }) + .await; } #[tokio::test] async fn clear_only_ui_reset_preserves_chat_session_state() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); - app.chat_widget.handle_codex_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, + app.chat_widget + .handle_thread_session(crate::session_state::ThreadSessionState { + thread_id, forked_from_id: None, + fork_parent_title: None, thread_name: Some("keep me".to_string()), model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), @@ -5025,14 +5032,13 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, 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()), - }), - }); + }); app.chat_widget .apply_external_edit("draft prompt".to_string()); app.transcript_cells = vec![Arc::new(UserHistoryCell { diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 2c4e560af..5b278bd2c 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 d80c3f4fc..009121f78 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, })) @@ -278,14 +257,14 @@ impl App { changes: self .thread_file_change_changes(thread_id, ¶ms.turn_id, ¶ms.item_id) .await - .map(crate::app_server_approval_conversions::file_update_changes_to_core) + .map(crate::app_server_approval_conversions::file_update_changes_to_display) .unwrap_or_default(), }), ), ServerRequest::McpServerElicitationRequest { request_id, params } => { if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( thread_id, - app_server_request_id_to_mcp_request_id(request_id), + request_id.clone(), params.clone(), ) { Some(ThreadInteractiveRequest::McpServerElicitation(request)) @@ -295,7 +274,7 @@ impl App { thread_id, thread_label, server_name: params.server_name.clone(), - request_id: app_server_request_id_to_mcp_request_id(request_id), + request_id: request_id.clone(), message: match ¶ms.request { codex_app_server_protocol::McpServerElicitationRequest::Form { message, @@ -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, @@ -618,12 +597,12 @@ impl App { thread_id, items.to_vec(), cwd.clone(), - approval_policy, + *approval_policy, approvals_reviewer, permission_profile.clone(), active_permission_profile, model.to_string(), - effort, + *effort, *summary, *service_tier, collaboration_mode.clone(), @@ -634,12 +613,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, @@ -647,76 +626,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::ApproveGuardianDeniedAction { event } - | AppCommandView::Other(Op::ApproveGuardianDeniedAction { event }) => { + AppCommand::OverrideTurnContext { .. } => Ok(true), + AppCommand::ApproveGuardianDeniedAction { event } => { app_server .thread_approve_guardian_denied_action(thread_id, event) .await?; @@ -1016,7 +986,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); @@ -1327,7 +1297,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 efe0adf84..25ee6cd14 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::ActivePermissionProfile; @@ -12,7 +13,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 @@ -63,7 +64,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(), active_permission_profile: active_permission_profile.clone(), @@ -118,15 +121,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; @@ -189,7 +193,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()); @@ -243,23 +247,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")) @@ -274,7 +282,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 b9770b5c5..231e5e9cb 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 @@ -497,8 +497,8 @@ impl App { self.backtrack.pending_rollback = None; } - /// Apply rollback semantics for `ThreadRolledBack` events where this TUI does not have an - /// in-flight backtrack request (`pending_rollback` is `None`). + /// Apply rollback semantics for a confirmed rollback where this TUI does + /// not have an in-flight backtrack request (`pending_rollback` is `None`). /// /// Returns `true` when local transcript state changed. pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool { diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index d60da69d1..8633da04b 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -1,39 +1,38 @@ 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::McpServerElicitationAction; +use codex_app_server_protocol::RequestId as AppServerRequestId; +use codex_app_server_protocol::ReviewTarget; +use codex_app_server_protocol::ThreadRealtimeAudioChunk; +use codex_app_server_protocol::ThreadRealtimeStartTransport; +use codex_app_server_protocol::ToolRequestUserInputResponse; +use codex_app_server_protocol::UserInput; 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; use codex_protocol::config_types::ServiceTier; 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; - #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) enum AppCommand { Interrupt, CleanBackgroundTerminals, - RealtimeConversationStart(ConversationStartParams), - RealtimeConversationAudio(ConversationAudioParams), - RealtimeConversationText(ConversationTextParams), + RealtimeConversationStart { + transport: Option, + voice: Option, + }, + RealtimeConversationAudio(ThreadRealtimeAudioChunk), RealtimeConversationClose, RunUserShellCommand { command: String, @@ -68,22 +67,22 @@ pub(crate) enum AppCommand { ExecApproval { id: String, turn_id: Option, - decision: ReviewDecision, + decision: CommandExecutionApprovalDecision, }, PatchApproval { id: String, - decision: ReviewDecision, + decision: FileChangeApprovalDecision, }, ResolveElicitation { server_name: String, - request_id: McpRequestId, - decision: ElicitationAction, + request_id: AppServerRequestId, + decision: McpServerElicitationAction, content: Option, meta: Option, }, UserInputAnswer { id: String, - response: RequestUserInputResponse, + response: ToolRequestUserInputResponse, }, RequestPermissionsResponse { id: String, @@ -103,97 +102,18 @@ pub(crate) enum AppCommand { num_turns: u32, }, Review { - review_request: ReviewRequest, + target: ReviewTarget, + }, + AddToHistory { + text: String, + }, + GetHistoryEntryRequest { + offset: usize, + log_id: u64, }, ApproveGuardianDeniedAction { event: GuardianAssessmentEvent, }, - Other(Op), -} - -#[allow(clippy::large_enum_variant)] -#[allow(dead_code)] -pub(crate) enum AppCommandView<'a> { - Interrupt, - CleanBackgroundTerminals, - RealtimeConversationStart(&'a ConversationStartParams), - RealtimeConversationAudio(&'a ConversationAudioParams), - RealtimeConversationText(&'a ConversationTextParams), - RealtimeConversationClose, - RunUserShellCommand { - command: &'a str, - }, - UserTurn { - items: &'a [UserInput], - cwd: &'a PathBuf, - approval_policy: AskForApproval, - approvals_reviewer: &'a Option, - permission_profile: &'a PermissionProfile, - model: &'a str, - effort: Option, - summary: &'a Option, - service_tier: &'a Option>, - final_output_json_schema: &'a Option, - collaboration_mode: &'a Option, - personality: &'a 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, - }, - ExecApproval { - id: &'a str, - turn_id: &'a Option, - decision: &'a ReviewDecision, - }, - PatchApproval { - id: &'a str, - decision: &'a ReviewDecision, - }, - ResolveElicitation { - server_name: &'a str, - request_id: &'a McpRequestId, - decision: &'a ElicitationAction, - content: &'a Option, - meta: &'a Option, - }, - UserInputAnswer { - id: &'a str, - response: &'a RequestUserInputResponse, - }, - RequestPermissionsResponse { - id: &'a str, - response: &'a RequestPermissionsResponse, - }, - ReloadUserConfig, - ListSkills { - cwds: &'a [PathBuf], - force_reload: bool, - }, - Compact, - SetThreadName { - name: &'a str, - }, - Shutdown, - ThreadRollback { - num_turns: u32, - }, - Review { - review_request: &'a ReviewRequest, - }, - ApproveGuardianDeniedAction { - event: &'a GuardianAssessmentEvent, - }, - Other(&'a Op), } impl AppCommand { @@ -205,13 +125,16 @@ impl AppCommand { Self::CleanBackgroundTerminals } - pub(crate) fn realtime_conversation_start(params: ConversationStartParams) -> Self { - Self::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::RealtimeConversationAudio(params) + pub(crate) fn realtime_conversation_audio(frame: ThreadRealtimeAudioChunk) -> Self { + Self::RealtimeConversationAudio(frame) } pub(crate) fn realtime_conversation_close() -> Self { @@ -284,7 +207,7 @@ impl AppCommand { pub(crate) fn exec_approval( id: String, turn_id: Option, - decision: ReviewDecision, + decision: CommandExecutionApprovalDecision, ) -> Self { Self::ExecApproval { id, @@ -293,14 +216,14 @@ impl AppCommand { } } - pub(crate) fn patch_approval(id: String, decision: ReviewDecision) -> Self { + pub(crate) fn patch_approval(id: String, decision: FileChangeApprovalDecision) -> Self { Self::PatchApproval { id, decision } } pub(crate) fn resolve_elicitation( server_name: String, - request_id: McpRequestId, - decision: ElicitationAction, + request_id: AppServerRequestId, + decision: McpServerElicitationAction, content: Option, meta: Option, ) -> Self { @@ -313,7 +236,7 @@ impl AppCommand { } } - pub(crate) fn user_input_answer(id: String, response: RequestUserInputResponse) -> Self { + pub(crate) fn user_input_answer(id: String, response: ToolRequestUserInputResponse) -> Self { Self::UserInputAnswer { id, response } } @@ -340,419 +263,34 @@ impl AppCommand { Self::SetThreadName { name } } + #[allow(dead_code)] + pub(crate) fn shutdown() -> Self { + Self::Shutdown + } + pub(crate) fn thread_rollback(num_turns: u32) -> Self { Self::ThreadRollback { num_turns } } - pub(crate) fn review(review_request: ReviewRequest) -> Self { - Self::Review { review_request } + pub(crate) fn review(target: ReviewTarget) -> Self { + Self::Review { target } } - pub(crate) fn into_core(self) -> Op { - match self { - Self::Interrupt => Op::Interrupt, - Self::CleanBackgroundTerminals => Op::CleanBackgroundTerminals, - Self::RealtimeConversationStart(params) => Op::RealtimeConversationStart(params), - Self::RealtimeConversationAudio(params) => Op::RealtimeConversationAudio(params), - Self::RealtimeConversationText(params) => Op::RealtimeConversationText(params), - Self::RealtimeConversationClose => Op::RealtimeConversationClose, - Self::RunUserShellCommand { command } => Op::RunUserShellCommand { command }, - Self::UserTurn { - items, - cwd, - approval_policy, - approvals_reviewer, - permission_profile, - model, - effort, - summary, - service_tier, - final_output_json_schema, - collaboration_mode, - personality, - } => { - 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}" - ) - }); - Op::UserTurn { - items, - environments: None, - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile: Some(permission_profile), - model, - effort, - summary, - service_tier, - final_output_json_schema, - collaboration_mode, - personality, - } - } - Self::OverrideTurnContext { - cwd, - approval_policy, - approvals_reviewer, - permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } => Op::OverrideTurnContext { - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy: None, - permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - }, - Self::ExecApproval { - id, - turn_id, - decision, - } => Op::ExecApproval { - id, - turn_id, - decision, - }, - Self::PatchApproval { id, decision } => Op::PatchApproval { id, decision }, - Self::ResolveElicitation { - server_name, - request_id, - decision, - content, - meta, - } => Op::ResolveElicitation { - server_name, - request_id, - decision, - content, - meta, - }, - Self::UserInputAnswer { id, response } => Op::UserInputAnswer { id, response }, - Self::RequestPermissionsResponse { id, response } => { - Op::RequestPermissionsResponse { id, response } - } - Self::ReloadUserConfig => Op::ReloadUserConfig, - Self::ListSkills { cwds, force_reload } => Op::ListSkills { cwds, force_reload }, - Self::Compact => Op::Compact, - Self::SetThreadName { name } => Op::SetThreadName { name }, - Self::Shutdown => Op::Shutdown, - Self::ThreadRollback { num_turns } => Op::ThreadRollback { num_turns }, - Self::Review { review_request } => Op::Review { review_request }, - Self::ApproveGuardianDeniedAction { event } => { - Op::ApproveGuardianDeniedAction { event } - } - Self::Other(op) => op, - } + 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, Self::Review { .. }) } - - pub(crate) fn view(&self) -> AppCommandView<'_> { - match self { - Self::Interrupt => AppCommandView::Interrupt, - Self::CleanBackgroundTerminals => AppCommandView::CleanBackgroundTerminals, - Self::RealtimeConversationStart(params) => { - AppCommandView::RealtimeConversationStart(params) - } - Self::RealtimeConversationAudio(params) => { - AppCommandView::RealtimeConversationAudio(params) - } - Self::RealtimeConversationText(params) => { - AppCommandView::RealtimeConversationText(params) - } - Self::RealtimeConversationClose => AppCommandView::RealtimeConversationClose, - Self::RunUserShellCommand { command } => { - AppCommandView::RunUserShellCommand { command } - } - Self::UserTurn { - items, - cwd, - approval_policy, - approvals_reviewer, - permission_profile, - model, - effort, - summary, - service_tier, - final_output_json_schema, - collaboration_mode, - personality, - } => AppCommandView::UserTurn { - items, - cwd, - approval_policy: *approval_policy, - approvals_reviewer, - permission_profile, - model, - effort: *effort, - summary, - service_tier, - final_output_json_schema, - collaboration_mode, - personality, - }, - Self::OverrideTurnContext { - cwd, - approval_policy, - approvals_reviewer, - 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, - }, - Self::ExecApproval { - id, - turn_id, - decision, - } => AppCommandView::ExecApproval { - id, - turn_id, - decision, - }, - Self::PatchApproval { id, decision } => AppCommandView::PatchApproval { id, decision }, - Self::ResolveElicitation { - server_name, - request_id, - decision, - content, - meta, - } => AppCommandView::ResolveElicitation { - server_name, - request_id, - decision, - content, - meta, - }, - Self::UserInputAnswer { id, response } => { - AppCommandView::UserInputAnswer { id, response } - } - Self::RequestPermissionsResponse { id, response } => { - AppCommandView::RequestPermissionsResponse { id, response } - } - Self::ReloadUserConfig => AppCommandView::ReloadUserConfig, - Self::ListSkills { cwds, force_reload } => AppCommandView::ListSkills { - cwds, - force_reload: *force_reload, - }, - Self::Compact => AppCommandView::Compact, - Self::SetThreadName { name } => AppCommandView::SetThreadName { name }, - Self::Shutdown => AppCommandView::Shutdown, - Self::ThreadRollback { num_turns } => AppCommandView::ThreadRollback { - num_turns: *num_turns, - }, - Self::Review { review_request } => AppCommandView::Review { review_request }, - Self::ApproveGuardianDeniedAction { event } => { - AppCommandView::ApproveGuardianDeniedAction { event } - } - Self::Other(op) => AppCommandView::Other(op), - } - } -} - -impl From for AppCommand { - fn from(value: Op) -> Self { - match value { - Op::Interrupt => Self::Interrupt, - Op::CleanBackgroundTerminals => Self::CleanBackgroundTerminals, - Op::RealtimeConversationStart(params) => Self::RealtimeConversationStart(params), - Op::RealtimeConversationAudio(params) => Self::RealtimeConversationAudio(params), - Op::RealtimeConversationText(params) => Self::RealtimeConversationText(params), - Op::RealtimeConversationClose => Self::RealtimeConversationClose, - Op::RunUserShellCommand { command } => Self::RunUserShellCommand { command }, - Op::UserTurn { - items, - environments, - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile: Some(permission_profile), - model, - effort, - summary, - service_tier, - final_output_json_schema, - collaboration_mode, - personality, - } => { - if environments.is_none() - && legacy_compatible_permission_profile(&permission_profile, cwd.as_path()) - .to_legacy_sandbox_policy(cwd.as_path()) - .is_ok_and(|compatible_policy| compatible_policy == sandbox_policy) - { - Self::UserTurn { - items, - cwd, - approval_policy, - approvals_reviewer, - permission_profile, - model, - effort, - summary, - service_tier, - final_output_json_schema, - collaboration_mode, - personality, - } - } else { - Self::Other(Op::UserTurn { - items, - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile: Some(permission_profile), - model, - effort, - summary, - service_tier, - final_output_json_schema, - collaboration_mode, - personality, - environments, - }) - } - } - Op::OverrideTurnContext { - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } => { - if sandbox_policy.is_none() { - Self::OverrideTurnContext { - cwd, - approval_policy, - approvals_reviewer, - permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } - } else { - Self::Other(Op::OverrideTurnContext { - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - }) - } - } - Op::ExecApproval { - id, - turn_id, - decision, - } => Self::ExecApproval { - id, - turn_id, - decision, - }, - Op::PatchApproval { id, decision } => Self::PatchApproval { id, decision }, - Op::ResolveElicitation { - server_name, - request_id, - decision, - content, - meta, - } => Self::ResolveElicitation { - server_name, - request_id, - decision, - content, - meta, - }, - Op::UserInputAnswer { id, response } => Self::UserInputAnswer { id, response }, - Op::RequestPermissionsResponse { id, response } => { - Self::RequestPermissionsResponse { id, response } - } - Op::ReloadUserConfig => Self::ReloadUserConfig, - Op::ListSkills { cwds, force_reload } => Self::ListSkills { cwds, force_reload }, - Op::Compact => Self::Compact, - Op::SetThreadName { name } => Self::SetThreadName { name }, - Op::Shutdown => Self::Shutdown, - Op::ThreadRollback { num_turns } => Self::ThreadRollback { num_turns }, - Op::Review { review_request } => Self::Review { review_request }, - Op::ApproveGuardianDeniedAction { event } => { - Self::ApproveGuardianDeniedAction { event } - } - op => Self::Other(op), - } - } -} - -impl PartialEq for AppCommand { - fn eq(&self, other: &Op) -> bool { - self.clone().into_core() == *other - } -} - -impl PartialEq for Op { - fn eq(&self, other: &AppCommand) -> bool { - *self == other.clone().into_core() - } -} - -impl From<&Op> for AppCommand { - fn from(value: &Op) -> Self { - Self::from(value.clone()) - } } impl From<&AppCommand> for AppCommand { @@ -760,9 +298,3 @@ impl From<&AppCommand> for AppCommand { value.clone() } } - -impl From for Op { - fn from(value: AppCommand) -> Self { - value.into_core() - } -} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 87d7484b9..07c085578 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -22,13 +22,13 @@ 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::RateLimitSnapshot; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; @@ -37,6 +37,7 @@ 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; @@ -45,7 +46,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; @@ -63,6 +63,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 { @@ -139,7 +146,7 @@ pub(crate) enum AppEvent { /// Deliver a synthetic history lookup response to a specific thread channel. ThreadHistoryEntryResponse { thread_id: ThreadId, - event: GetHistoryEntryResponseEvent, + event: HistoryLookupResponse, }, /// Start a new session. @@ -181,7 +188,7 @@ 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(AppCommand), diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index e53982322..5b8da9ec0 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -1,14 +1,20 @@ +//! Convenience sender for app events and common outbound TUI commands. +//! +//! This wraps the raw channel so call sites can submit typed `AppCommand`s +//! without duplicating event construction or session logging behavior. + 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::McpServerElicitationAction; +use codex_app_server_protocol::RequestId as AppServerRequestId; +use codex_app_server_protocol::ReviewTarget; +use codex_app_server_protocol::ThreadRealtimeAudioChunk; +use codex_app_server_protocol::ToolRequestUserInputResponse; 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; use crate::app_event::AppEvent; @@ -49,8 +55,8 @@ impl AppEventSender { 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))); + 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) { @@ -61,19 +67,24 @@ impl AppEventSender { } #[cfg_attr(target_os = "linux", allow(dead_code))] - pub(crate) fn realtime_conversation_audio(&self, params: ConversationAudioParams) { + pub(crate) fn realtime_conversation_audio(&self, frame: ThreadRealtimeAudioChunk) { self.send(AppEvent::CodexOp(AppCommand::realtime_conversation_audio( - params, + frame, ))); } - pub(crate) fn user_input_answer(&self, id: String, response: RequestUserInputResponse) { + pub(crate) fn user_input_answer(&self, id: String, response: ToolRequestUserInputResponse) { 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), @@ -92,7 +103,12 @@ impl AppEventSender { }); } - 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), @@ -103,8 +119,8 @@ impl AppEventSender { &self, thread_id: ThreadId, server_name: String, - request_id: McpRequestId, - decision: ElicitationAction, + request_id: AppServerRequestId, + decision: McpServerElicitationAction, content: Option, meta: Option, ) { diff --git a/codex-rs/tui/src/app_server_approval_conversions.rs b/codex-rs/tui/src/app_server_approval_conversions.rs index 894bd36ed..922e0ffd5 100644 --- a/codex-rs/tui/src/app_server_approval_conversions.rs +++ b/codex-rs/tui/src/app_server_approval_conversions.rs @@ -1,37 +1,19 @@ +//! Narrow conversion helpers for approval-related app-server payloads. +//! +//! The TUI mostly keeps app-server approval types intact. These helpers cover +//! the remaining cases where the UI consumes a private file-change display +//! model or needs to translate a granted permission response for outbound +//! submission. + +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 as AppServerNetworkApprovalContext; 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; -pub(crate) fn network_approval_context_to_core( - value: AppServerNetworkApprovalContext, -) -> NetworkApprovalContext { - NetworkApprovalContext { - host: value.host, - protocol: match value.protocol { - codex_app_server_protocol::NetworkApprovalProtocol::Http => { - NetworkApprovalProtocol::Http - } - codex_app_server_protocol::NetworkApprovalProtocol::Https => { - NetworkApprovalProtocol::Https - } - codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp => { - NetworkApprovalProtocol::Socks5Tcp - } - codex_app_server_protocol::NetworkApprovalProtocol::Socks5Udp => { - NetworkApprovalProtocol::Socks5Udp - } - }, - } -} - pub(crate) fn granted_permission_profile_from_request( value: CoreRequestPermissionProfile, ) -> GrantedPermissionProfile { @@ -43,7 +25,7 @@ pub(crate) fn granted_permission_profile_from_request( } } -pub(crate) fn file_update_changes_to_core( +pub(crate) fn file_update_changes_to_display( changes: Vec, ) -> HashMap { changes @@ -69,20 +51,11 @@ pub(crate) fn file_update_changes_to_core( #[cfg(test)] mod tests { - use super::file_update_changes_to_core; + use super::file_update_changes_to_display; 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::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; @@ -94,23 +67,9 @@ mod tests { } #[test] - fn converts_app_server_network_approval_context_to_core() { + fn converts_file_update_changes_to_display() { assert_eq!( - network_approval_context_to_core(codex_app_server_protocol::NetworkApprovalContext { - host: "example.com".to_string(), - protocol: codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp, - }), - NetworkApprovalContext { - host: "example.com".to_string(), - protocol: NetworkApprovalProtocol::Socks5Tcp, - } - ); - } - - #[test] - fn converts_file_update_changes_to_core() { - assert_eq!( - file_update_changes_to_core(vec![FileUpdateChange { + file_update_changes_to_display(vec![FileUpdateChange { path: "foo.txt".to_string(), kind: PatchChangeKind::Add, diff: "hello\n".to_string(), @@ -127,15 +86,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 +129,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 4a62036e0..93d671fbc 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1,9 +1,15 @@ +//! App-server session facade used by the TUI event loop. +//! +//! This module owns the typed JSON-RPC calls needed by the TUI and keeps +//! request/response plumbing out of `App` and `ChatWidget`. + use crate::bottom_pane::FeedbackAudience; #[cfg(test)] 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 +17,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; @@ -31,10 +38,12 @@ use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; +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; @@ -66,8 +75,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,8 +101,10 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; +use codex_app_server_protocol::UserInput; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; +use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; @@ -103,18 +113,6 @@ 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; @@ -149,37 +147,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. - /// - /// App-server responses may echo the experimental profile payload; when - /// they do, the TUI uses that exact runtime profile. Older/missing - /// responses fall back to the local profile for embedded sessions or to a - /// response-cwd legacy sandbox projection for remote sessions so cached - /// sessions do not reinterpret cwd-bound grants. - pub(crate) permission_profile: PermissionProfile, - /// Named or implicit built-in profile that produced `permission_profile`, - /// when the server knows it. - pub(crate) active_permission_profile: Option, - 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, @@ -542,7 +509,7 @@ impl AppServerSession { pub(crate) async fn turn_start( &mut self, thread_id: ThreadId, - items: Vec, + items: Vec, cwd: PathBuf, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, @@ -568,11 +535,11 @@ impl AppServerSession { request_id, params: TurnStartParams { thread_id: thread_id.to_string(), - input: items.into_iter().map(Into::into).collect(), + input: items, 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, permissions, @@ -617,7 +584,7 @@ impl AppServerSession { &mut self, thread_id: ThreadId, turn_id: String, - items: Vec, + items: Vec, ) -> std::result::Result { let request_id = self.next_request_id(); self.client @@ -625,7 +592,7 @@ impl AppServerSession { request_id, params: TurnSteerParams { thread_id: thread_id.to_string(), - input: items.into_iter().map(Into::into).collect(), + input: items, responsesapi_client_metadata: None, expected_turn_id: turn_id, }, @@ -863,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 @@ -871,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), }, }) @@ -911,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, - realtime_session_id: params.realtime_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(()) @@ -942,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 @@ -951,7 +903,7 @@ impl AppServerSession { request_id, params: ThreadRealtimeAppendAudioParams { thread_id: thread_id.to_string(), - audio: params.frame.into(), + audio: frame, }, }) .await @@ -959,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 @@ -1025,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, @@ -1363,7 +1323,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(), permission_profile, response.active_permission_profile.clone().map(Into::into), @@ -1395,7 +1355,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(), permission_profile, response.active_permission_profile.clone().map(Into::into), @@ -1427,7 +1387,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(), permission_profile, response.active_permission_profile.clone().map(Into::into), @@ -1457,25 +1417,6 @@ fn permission_profile_from_thread_response( } } -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" @@ -1528,69 +1469,32 @@ 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 crate::legacy_core::config::ConfigOverrides; + 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; @@ -1756,8 +1660,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 { @@ -1772,8 +1677,8 @@ mod tests { ], glob_scan_max_depth: None, }, - network: NetworkSandboxPolicy::Restricted, - }; + } + .into(); assert_eq!( sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()), @@ -1784,8 +1689,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 { @@ -1802,8 +1708,8 @@ mod tests { ], glob_scan_max_depth: None, }, - network: NetworkSandboxPolicy::Restricted, - }; + } + .into(); assert_eq!( sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()), @@ -1897,7 +1803,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, @@ -1931,7 +1837,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()) @@ -1968,8 +1874,8 @@ mod tests { .to_legacy_sandbox_policy(cwd.as_path()) .expect("read-only profile must be legacy-compatible") .into(); - let split_profile = PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Restricted { + let response_profile = AppServerPermissionProfile::Managed { + file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1988,9 +1894,9 @@ mod tests { ], glob_scan_max_depth: None, }, - network: NetworkSandboxPolicy::Restricted, + network: PermissionProfileNetworkPermissions { enabled: false }, }; - let response_profile = split_profile.clone().into(); + let split_profile: PermissionProfile = response_profile.clone().into(); assert_eq!( permission_profile_from_thread_response( diff --git a/codex-rs/tui/src/auto_review_denials.rs b/codex-rs/tui/src/auto_review_denials.rs index 16a8e4305..e51e071e2 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 a87ccddb7..43ff94618 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 @@ -use codex_protocol::ThreadId; -use codex_protocol::approvals::ElicitationAction; -use codex_protocol::mcp::RequestId as McpRequestId; #[cfg(test)] -use codex_protocol::protocol::Op; +use crate::app_command::AppCommand as Op; +use codex_app_server_protocol::McpServerElicitationAction; +use codex_app_server_protocol::RequestId as AppServerRequestId; +use codex_protocol::ThreadId; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -50,7 +50,7 @@ pub(crate) enum AppLinkSuggestionType { pub(crate) struct AppLinkElicitationTarget { pub(crate) thread_id: ThreadId, pub(crate) server_name: String, - pub(crate) request_id: McpRequestId, + pub(crate) request_id: AppServerRequestId, } pub(crate) struct AppLinkViewParams { @@ -148,7 +148,7 @@ impl AppLinkView { self.elicitation_target.is_some() } - fn resolve_elicitation(&self, decision: ElicitationAction) { + fn resolve_elicitation(&self, decision: McpServerElicitationAction) { let Some(target) = self.elicitation_target.as_ref() else { return; }; @@ -163,7 +163,7 @@ impl AppLinkView { } fn decline_tool_suggestion(&mut self) { - self.resolve_elicitation(ElicitationAction::Decline); + self.resolve_elicitation(McpServerElicitationAction::Decline); self.complete = true; } @@ -182,7 +182,7 @@ impl AppLinkView { force_refetch: true, }); if self.is_tool_suggestion() { - self.resolve_elicitation(ElicitationAction::Accept); + self.resolve_elicitation(McpServerElicitationAction::Accept); } self.complete = true; } @@ -199,7 +199,7 @@ impl AppLinkView { enabled: self.is_enabled, }); if self.is_tool_suggestion() { - self.resolve_elicitation(ElicitationAction::Accept); + self.resolve_elicitation(McpServerElicitationAction::Accept); self.complete = true; } } @@ -466,7 +466,7 @@ impl BottomPaneView for AppLinkView { fn on_ctrl_c(&mut self) -> CancellationEvent { if self.is_tool_suggestion() { - self.resolve_elicitation(ElicitationAction::Decline); + self.resolve_elicitation(McpServerElicitationAction::Decline); } self.complete = true; CancellationEvent::Handled @@ -582,7 +582,7 @@ mod tests { thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001") .expect("valid thread id"), server_name: "codex_apps".to_string(), - request_id: McpRequestId::String("request-1".to_string()), + request_id: AppServerRequestId::String("request-1".to_string()), } } @@ -856,8 +856,8 @@ mod tests { op, Op::ResolveElicitation { server_name: "codex_apps".to_string(), - request_id: McpRequestId::String("request-1".to_string()), - decision: ElicitationAction::Accept, + request_id: AppServerRequestId::String("request-1".to_string()), + decision: McpServerElicitationAction::Accept, content: None, meta: None, } @@ -898,8 +898,8 @@ mod tests { op, Op::ResolveElicitation { server_name: "codex_apps".to_string(), - request_id: McpRequestId::String("request-1".to_string()), - decision: ElicitationAction::Decline, + request_id: AppServerRequestId::String("request-1".to_string()), + decision: McpServerElicitationAction::Decline, content: None, meta: None, } @@ -948,8 +948,8 @@ mod tests { op, Op::ResolveElicitation { server_name: "codex_apps".to_string(), - request_id: McpRequestId::String("request-1".to_string()), - decision: ElicitationAction::Accept, + request_id: AppServerRequestId::String("request-1".to_string()), + decision: McpServerElicitationAction::Accept, content: None, meta: None, } @@ -984,7 +984,7 @@ mod tests { assert!( view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { server_name: "codex_apps".to_string(), - request_id: McpRequestId::String("request-1".to_string()), + request_id: AppServerRequestId::String("request-1".to_string()), }) ); assert!(view.is_complete()); @@ -1013,7 +1013,7 @@ mod tests { assert!( !view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { server_name: "other_server".to_string(), - request_id: McpRequestId::String("request-1".to_string()), + request_id: AppServerRequestId::String("request-1".to_string()), }) ); assert!(!view.is_complete()); diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index e8b9e2c06..1089d3668 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -15,6 +15,8 @@ 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::bottom_pane::BottomPaneView; @@ -23,8 +25,10 @@ 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::history_cell::ReviewDecision; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; @@ -34,19 +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::McpServerElicitationAction; +use codex_app_server_protocol::NetworkApprovalContext; +use codex_app_server_protocol::NetworkPolicyRuleAction; +use codex_app_server_protocol::RequestId; use codex_features::Features; use codex_protocol::ThreadId; -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; -use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -71,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, }, @@ -302,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()); } ( @@ -313,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()); } ( @@ -334,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)); @@ -401,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() @@ -417,7 +432,7 @@ impl ApprovalOverlay { &self, server_name: &str, request_id: &RequestId, - decision: ElicitationAction, + decision: McpServerElicitationAction, ) { let Some(thread_id) = self .current_request @@ -453,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, @@ -467,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, @@ -477,7 +496,7 @@ impl ApprovalOverlay { self.handle_elicitation_decision( server_name, request_id, - ElicitationAction::Cancel, + McpServerElicitationAction::Cancel, ); } } @@ -726,9 +745,10 @@ fn build_header(request: &ApprovalRequest) -> Box { #[derive(Clone)] enum ApprovalDecision { - Review(ReviewDecision), + Command(CommandExecutionApprovalDecision), + FileChange(FileChangeApprovalDecision), Permissions(PermissionsDecision), - McpElicitation(ElicitationAction), + McpElicitation(McpServerElicitationAction), } #[derive(Clone, Copy)] @@ -746,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, @@ -755,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; } @@ -777,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() { @@ -793,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 { @@ -811,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(), }), }) @@ -849,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() { @@ -858,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() { @@ -867,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() { @@ -883,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>( @@ -921,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(), }, ] @@ -996,17 +1049,17 @@ fn elicitation_options(keymap: &ApprovalKeymap) -> Vec { vec![ ApprovalOption { label: "Yes, provide the requested info".to_string(), - decision: ApprovalDecision::McpElicitation(ElicitationAction::Accept), + decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Accept), shortcuts: keymap.approve.clone(), }, ApprovalOption { label: "No, but continue without it".to_string(), - decision: ApprovalDecision::McpElicitation(ElicitationAction::Decline), + decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Decline), shortcuts: decline_shortcuts, }, ApprovalOption { label: "Cancel this request".to_string(), - decision: ApprovalDecision::McpElicitation(ElicitationAction::Cancel), + decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Cancel), shortcuts: cancel_shortcuts, }, ] @@ -1015,16 +1068,14 @@ fn elicitation_options(keymap: &ApprovalKeymap) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::app_command::AppCommand; 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; @@ -1100,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, } @@ -1165,7 +1219,7 @@ mod tests { let mut decision = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ExecApproval { decision: d, .. }, + op: Op::ExecApproval { decision: d, .. }, .. } = ev { @@ -1173,7 +1227,7 @@ mod tests { break; } } - assert_eq!(decision, Some(ReviewDecision::Abort)); + assert_eq!(decision, Some(CommandExecutionApprovalDecision::Cancel)); } #[test] @@ -1196,7 +1250,7 @@ mod tests { let mut decision = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ResolveElicitation { decision: d, .. }, + op: Op::ResolveElicitation { decision: d, .. }, .. } = ev { @@ -1204,7 +1258,7 @@ mod tests { break; } } - assert_eq!(decision, Some(ElicitationAction::Cancel)); + assert_eq!(decision, Some(McpServerElicitationAction::Cancel)); } #[test] @@ -1236,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, }, @@ -1249,11 +1306,11 @@ mod tests { let mut saw_denied = false; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ExecApproval { decision, .. }, + op: Op::ExecApproval { decision, .. }, .. } = ev { - assert_eq!(decision, ReviewDecision::Denied); + assert_eq!(decision, CommandExecutionApprovalDecision::Decline); saw_denied = true; break; } @@ -1277,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(), }, ], @@ -1297,13 +1354,13 @@ mod tests { let mut saw_deny = false; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ExecApproval { decision, .. }, + op: Op::ExecApproval { decision, .. }, .. } = ev { assert_eq!( decision, - ReviewDecision::NetworkPolicyAmendment { + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment: amendment } ); @@ -1350,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, }, @@ -1381,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, }, @@ -1416,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, }, @@ -1442,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, @@ -1460,16 +1526,16 @@ mod tests { let mut saw_op = false; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ExecApproval { decision, .. }, + op: Op::ExecApproval { decision, .. }, .. } = ev { 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; @@ -1494,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(), @@ -1532,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, }; @@ -1568,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, @@ -1600,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, @@ -1624,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, @@ -1668,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, @@ -1682,10 +1760,9 @@ mod tests { }, access: FileSystemAccessMode::None, }, - ], + ]), glob_scan_max_depth: None, }), - ..Default::default() }; assert_eq!( @@ -1705,7 +1782,7 @@ mod tests { let mut saw_op = false; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::RequestPermissionsResponse { response, .. }, + op: Op::RequestPermissionsResponse { response, .. }, .. } = ev { @@ -1740,7 +1817,7 @@ mod tests { let mut saw_op = false; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::RequestPermissionsResponse { response, .. }, + op: Op::RequestPermissionsResponse { response, .. }, .. } = ev { @@ -1768,7 +1845,7 @@ mod tests { let mut saw_op = false; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::RequestPermissionsResponse { response, .. }, + op: Op::RequestPermissionsResponse { response, .. }, .. } = ev { @@ -1794,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(), + ), }), }; @@ -1844,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(), + ), }), }; @@ -1919,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(), @@ -2031,7 +2120,7 @@ mod tests { let mut decision = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ResolveElicitation { decision: d, .. }, + op: Op::ResolveElicitation { decision: d, .. }, .. } = ev { @@ -2039,7 +2128,7 @@ mod tests { break; } } - assert_eq!(decision, Some(ElicitationAction::Cancel)); + assert_eq!(decision, Some(McpServerElicitationAction::Cancel)); } #[test] @@ -2065,7 +2154,7 @@ mod tests { let mut esc_decision = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ResolveElicitation { decision, .. }, + op: Op::ResolveElicitation { decision, .. }, .. } = ev { @@ -2073,7 +2162,7 @@ mod tests { break; } } - assert_eq!(esc_decision, Some(ElicitationAction::Cancel)); + assert_eq!(esc_decision, Some(McpServerElicitationAction::Cancel)); let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); @@ -2095,7 +2184,7 @@ mod tests { let mut n_decision = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ResolveElicitation { decision, .. }, + op: Op::ResolveElicitation { decision, .. }, .. } = ev { @@ -2103,7 +2192,7 @@ mod tests { break; } } - assert_eq!(n_decision, Some(ElicitationAction::Decline)); + assert_eq!(n_decision, Some(McpServerElicitationAction::Decline)); } #[test] @@ -2121,7 +2210,7 @@ mod tests { let mut decision = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ExecApproval { decision: d, .. }, + op: Op::ExecApproval { decision: d, .. }, .. } = ev { @@ -2129,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/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index cbd1b1690..780ae77f2 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -2,7 +2,7 @@ use crate::app::app_server_requests::ResolvedAppServerRequest; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::McpServerElicitationFormRequest; use crate::render::renderable::Renderable; -use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_app_server_protocol::ToolRequestUserInputParams; use crossterm::event::KeyEvent; use super::CancellationEvent; @@ -101,8 +101,8 @@ pub(crate) trait BottomPaneView: Renderable { /// consumed. fn try_consume_user_input_request( &mut self, - request: RequestUserInputEvent, - ) -> Option { + request: ToolRequestUserInputParams, + ) -> Option { Some(request) } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c87974342..3a88e0883 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 1db113749..52ae81122 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -15,12 +15,11 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; -use crate::app_command::AppCommand; +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. @@ -599,9 +598,7 @@ impl ChatComposerHistory { boundary_if_exhausted, }); } - app_event_tx.send(AppEvent::CodexOp(AppCommand::from( - Op::GetHistoryEntryRequest { offset, log_id }, - ))); + app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(offset, log_id))); return HistorySearchResult::Pending; } @@ -719,12 +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(AppCommand::from( - 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 5de9b6437..f307c28d7 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -2,18 +2,16 @@ 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; +use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_protocol::ThreadId; -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; @@ -166,7 +164,7 @@ struct McpToolApprovalDisplayParam { pub(crate) struct McpServerElicitationFormRequest { thread_id: ThreadId, server_name: String, - request_id: McpRequestId, + request_id: AppServerRequestId, message: String, approval_display_params: Vec, response_mode: McpServerElicitationResponseMode, @@ -206,7 +204,7 @@ impl FooterTip { impl McpServerElicitationFormRequest { pub(crate) fn from_app_server_request( thread_id: ThreadId, - request_id: McpRequestId, + request_id: AppServerRequestId, request: McpServerElicitationRequestParams, ) -> Option { let McpServerElicitationRequestParams { @@ -234,33 +232,10 @@ impl McpServerElicitationFormRequest { ) } - pub(crate) fn from_event( - thread_id: ThreadId, - request: ElicitationRequestEvent, - ) -> Option { - let ElicitationRequest::Form { - meta, - message, - requested_schema, - } = request.request - else { - return None; - }; - - Self::from_parts( - thread_id, - request.server_name, - request.id, - meta, - message, - requested_schema, - ) - } - fn from_parts( thread_id: ThreadId, server_name: String, - request_id: McpRequestId, + request_id: AppServerRequestId, meta: Option, message: String, requested_schema: Value, @@ -388,7 +363,7 @@ impl McpServerElicitationFormRequest { self.server_name.as_str() } - pub(crate) fn request_id(&self) -> &McpRequestId { + pub(crate) fn request_id(&self) -> &AppServerRequestId { &self.request_id } } @@ -1140,7 +1115,7 @@ impl McpServerElicitationOverlay { self.request.thread_id, self.request.server_name.clone(), self.request.request_id.clone(), - ElicitationAction::Cancel, + McpServerElicitationAction::Cancel, /*content*/ None, /*meta*/ None, ); @@ -1167,22 +1142,22 @@ impl McpServerElicitationOverlay { if self.request.response_mode == McpServerElicitationResponseMode::ApprovalAction { let (decision, meta) = match self.field_value(/*idx*/ 0).as_ref().and_then(Value::as_str) { - Some(APPROVAL_ACCEPT_ONCE_VALUE) => (ElicitationAction::Accept, None), + Some(APPROVAL_ACCEPT_ONCE_VALUE) => (McpServerElicitationAction::Accept, None), Some(APPROVAL_ACCEPT_SESSION_VALUE) => ( - ElicitationAction::Accept, + McpServerElicitationAction::Accept, Some(serde_json::json!({ APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_SESSION_VALUE, })), ), Some(APPROVAL_ACCEPT_ALWAYS_VALUE) => ( - ElicitationAction::Accept, + McpServerElicitationAction::Accept, Some(serde_json::json!({ APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_ALWAYS_VALUE, })), ), - Some(APPROVAL_DECLINE_VALUE) => (ElicitationAction::Decline, None), - Some(APPROVAL_CANCEL_VALUE) => (ElicitationAction::Cancel, None), - _ => (ElicitationAction::Cancel, None), + Some(APPROVAL_DECLINE_VALUE) => (McpServerElicitationAction::Decline, None), + Some(APPROVAL_CANCEL_VALUE) => (McpServerElicitationAction::Cancel, None), + _ => (McpServerElicitationAction::Cancel, None), }; self.app_event_tx.resolve_elicitation( self.request.thread_id, @@ -1206,7 +1181,7 @@ impl McpServerElicitationOverlay { self.request.thread_id, self.request.server_name.clone(), self.request.request_id.clone(), - ElicitationAction::Accept, + McpServerElicitationAction::Accept, Some(Value::Object(content)), /*meta*/ None, ); @@ -1732,19 +1707,35 @@ mod tests { message: &str, requested_schema: Value, meta: Option, - ) -> ElicitationRequestEvent { - ElicitationRequestEvent { + ) -> McpServerElicitationRequestParams { + McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), turn_id: Some("turn-1".to_string()), server_name: "server-1".to_string(), - id: McpRequestId::String("request-1".to_string()), - request: ElicitationRequest::Form { + request: McpServerElicitationRequest::Form { meta, message: message.to_string(), - requested_schema, + requested_schema: serde_json::from_value(requested_schema) + .expect("test schema should deserialize"), }, } } + fn request_id(value: &str) -> AppServerRequestId { + AppServerRequestId::String(value.to_string()) + } + + fn from_form_request( + thread_id: ThreadId, + request: McpServerElicitationRequestParams, + ) -> Option { + McpServerElicitationFormRequest::from_app_server_request( + thread_id, + request_id("request-1"), + request, + ) + } + fn empty_object_schema() -> Value { serde_json::json!({ "type": "object", @@ -1816,7 +1807,7 @@ mod tests { #[test] fn parses_boolean_form_request() { let thread_id = ThreadId::default(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( thread_id, form_request( "Allow this request?", @@ -1841,7 +1832,7 @@ mod tests { McpServerElicitationFormRequest { thread_id, server_name: "server-1".to_string(), - request_id: McpRequestId::String("request-1".to_string()), + request_id: request_id("request-1"), message: "Allow this request?".to_string(), approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::FormContent, @@ -1873,7 +1864,7 @@ mod tests { #[test] fn unsupported_numeric_form_falls_back() { - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( ThreadId::default(), form_request( "Pick a number", @@ -1894,11 +1885,15 @@ mod tests { } #[test] - fn missing_schema_uses_approval_actions() { + fn empty_object_schema_uses_approval_actions() { let thread_id = ThreadId::default(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( thread_id, - form_request("Allow this request?", Value::Null, /*meta*/ None), + form_request( + "Allow this request?", + empty_object_schema(), + /*meta*/ None, + ), ) .expect("expected approval fallback"); @@ -1907,7 +1902,7 @@ mod tests { McpServerElicitationFormRequest { thread_id, server_name: "server-1".to_string(), - request_id: McpRequestId::String("request-1".to_string()), + request_id: request_id("request-1"), message: "Allow this request?".to_string(), approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::ApprovalAction, @@ -1945,7 +1940,7 @@ mod tests { #[test] fn empty_tool_approval_schema_uses_approval_actions() { let thread_id = ThreadId::default(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( thread_id, form_request( "Allow this request?", @@ -1964,7 +1959,7 @@ mod tests { McpServerElicitationFormRequest { thread_id, server_name: "server-1".to_string(), - request_id: McpRequestId::String("request-1".to_string()), + request_id: request_id("request-1"), message: "Allow this request?".to_string(), approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::ApprovalAction, @@ -1996,7 +1991,7 @@ mod tests { #[test] fn tool_suggestion_meta_is_parsed_into_request_payload() { - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( ThreadId::default(), form_request( "Suggest Google Calendar", @@ -2029,7 +2024,7 @@ mod tests { #[test] fn plugin_tool_suggestion_meta_without_install_url_is_parsed_into_request_payload() { - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( ThreadId::default(), form_request( "Suggest Slack", @@ -2061,7 +2056,7 @@ mod tests { #[test] fn tool_approval_display_params_prefer_explicit_display_order() { - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( ThreadId::default(), form_request( "Allow Calendar to create an event", @@ -2110,7 +2105,7 @@ mod tests { fn submit_sends_accept_with_typed_content() { let (tx, mut rx) = test_sender(); let thread_id = ThreadId::default(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( thread_id, form_request( "Allow this request?", @@ -2150,8 +2145,8 @@ mod tests { op, Op::ResolveElicitation { server_name: "server-1".to_string(), - request_id: McpRequestId::String("request-1".to_string()), - decision: ElicitationAction::Accept, + request_id: request_id("request-1"), + decision: McpServerElicitationAction::Accept, content: Some(serde_json::json!({ "confirmed": true, })), @@ -2164,7 +2159,7 @@ mod tests { fn empty_tool_approval_schema_session_choice_sets_persist_meta() { let (tx, mut rx) = test_sender(); let thread_id = ThreadId::default(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( thread_id, form_request( "Allow this request?", @@ -2204,8 +2199,8 @@ mod tests { op, Op::ResolveElicitation { server_name: "server-1".to_string(), - request_id: McpRequestId::String("request-1".to_string()), - decision: ElicitationAction::Accept, + request_id: request_id("request-1"), + decision: McpServerElicitationAction::Accept, content: None, meta: Some(serde_json::json!({ APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_SESSION_VALUE, @@ -2218,7 +2213,7 @@ mod tests { fn empty_tool_approval_schema_always_allow_sets_persist_meta() { let (tx, mut rx) = test_sender(); let thread_id = ThreadId::default(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( thread_id, form_request( "Allow this request?", @@ -2258,8 +2253,8 @@ mod tests { op, Op::ResolveElicitation { server_name: "server-1".to_string(), - request_id: McpRequestId::String("request-1".to_string()), - decision: ElicitationAction::Accept, + request_id: request_id("request-1"), + decision: McpServerElicitationAction::Accept, content: None, meta: Some(serde_json::json!({ APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_ALWAYS_VALUE, @@ -2272,7 +2267,7 @@ mod tests { fn ctrl_c_cancels_elicitation() { let (tx, mut rx) = test_sender(); let thread_id = ThreadId::default(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( thread_id, form_request( "Allow this request?", @@ -2311,8 +2306,8 @@ mod tests { op, Op::ResolveElicitation { server_name: "server-1".to_string(), - request_id: McpRequestId::String("request-1".to_string()), - decision: ElicitationAction::Cancel, + request_id: request_id("request-1"), + decision: McpServerElicitationAction::Cancel, content: None, meta: None, } @@ -2322,7 +2317,7 @@ mod tests { #[test] fn queues_requests_fifo() { let (tx, _rx) = test_sender(); - let first = McpServerElicitationFormRequest::from_event( + let first = from_form_request( ThreadId::default(), form_request( "First", @@ -2339,7 +2334,7 @@ mod tests { ), ) .expect("expected supported form"); - let second = McpServerElicitationFormRequest::from_event( + let second = from_form_request( ThreadId::default(), form_request( "Second", @@ -2356,7 +2351,7 @@ mod tests { ), ) .expect("expected supported form"); - let third = McpServerElicitationFormRequest::from_event( + let third = from_form_request( ThreadId::default(), form_request( "Third", @@ -2405,7 +2400,7 @@ mod tests { }, }); let mut overlay = McpServerElicitationOverlay::new( - McpServerElicitationFormRequest::from_event( + from_form_request( thread_id, form_request("First", supported_form_schema.clone(), /*meta*/ None), ) @@ -2416,16 +2411,18 @@ mod tests { /*disable_paste_burst*/ false, ); overlay.try_consume_mcp_server_elicitation_request( - McpServerElicitationFormRequest::from_event( + McpServerElicitationFormRequest::from_app_server_request( thread_id, - ElicitationRequestEvent { + request_id("request-2"), + McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), turn_id: Some("turn-2".to_string()), server_name: "server-1".to_string(), - id: McpRequestId::String("request-2".to_string()), - request: ElicitationRequest::Form { + request: McpServerElicitationRequest::Form { meta: None, message: "Second".to_string(), - requested_schema: supported_form_schema, + requested_schema: serde_json::from_value(supported_form_schema) + .expect("test schema should deserialize"), }, }, ) @@ -2435,7 +2432,7 @@ mod tests { assert!( overlay.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { server_name: "server-1".to_string(), - request_id: McpRequestId::String("request-1".to_string()), + request_id: request_id("request-1"), }) ); assert_eq!(overlay.request.message, "Second"); @@ -2447,7 +2444,7 @@ mod tests { assert!( overlay.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { server_name: "server-1".to_string(), - request_id: McpRequestId::String("request-2".to_string()), + request_id: request_id("request-2"), }) ); assert!(overlay.is_complete()); @@ -2460,7 +2457,7 @@ mod tests { #[test] fn boolean_form_snapshot() { let (tx, _rx) = test_sender(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( ThreadId::default(), form_request( "Allow this request?", @@ -2493,7 +2490,7 @@ mod tests { #[test] fn approval_form_tool_approval_snapshot() { let (tx, _rx) = test_sender(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( ThreadId::default(), form_request( "Allow this request?", @@ -2520,7 +2517,7 @@ mod tests { #[test] fn message_only_form_snapshot() { let (tx, _rx) = test_sender(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( ThreadId::default(), form_request( "Boolean elicit MCP example: do you confirm?", @@ -2543,7 +2540,7 @@ mod tests { #[test] fn message_only_form_with_persist_options_snapshot() { let (tx, _rx) = test_sender(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( ThreadId::default(), form_request( "Boolean elicit MCP example: do you confirm?", @@ -2571,7 +2568,7 @@ mod tests { #[test] fn approval_form_tool_approval_with_persist_options_snapshot() { let (tx, _rx) = test_sender(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( ThreadId::default(), form_request( "Allow this request?", @@ -2601,7 +2598,7 @@ mod tests { #[test] fn approval_form_tool_approval_with_param_summary_snapshot() { let (tx, _rx) = test_sender(); - let request = McpServerElicitationFormRequest::from_event( + let request = from_form_request( ThreadId::default(), form_request( "Allow Calendar to create an event", diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 02275755b..749be7ad1 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -31,11 +31,11 @@ use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; pub(crate) use bottom_pane_view::BottomPaneView; use bottom_pane_view::ViewCompletion; +use codex_app_server_protocol::ToolRequestUserInputParams; use codex_core_skills::model::SkillMetadata; use codex_features::Features; use codex_file_search::FileMatch; use codex_plugin::PluginCapabilitySummary; -use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -1205,7 +1205,7 @@ impl BottomPane { } /// Called when the agent requests user input. - pub fn push_user_input_request(&mut self, request: RequestUserInputEvent) { + pub fn push_user_input_request(&mut self, request: ToolRequestUserInputParams) { let request = if let Some(view) = self.view_stack.last_mut() { match view.try_consume_user_input_request(request) { Some(request) => request, @@ -1567,14 +1567,13 @@ impl Renderable for BottomPane { mod tests { use super::*; use crate::app::app_server_requests::ResolvedAppServerRequest; - use crate::app_command::AppCommand; + 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::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, @@ -1890,14 +1889,17 @@ mod tests { let mut approval_decision = None; while let Ok(event) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ExecApproval { decision, .. }, + op: Op::ExecApproval { decision, .. }, .. } = event { 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(), }]), }); @@ -2371,7 +2373,7 @@ mod tests { while let Ok(ev) = rx.try_recv() { assert!( - !matches!(ev, AppEvent::CodexOp(AppCommand::Interrupt)), + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), "expected Esc to not send Op::Interrupt when dismissing skill popup" ); } @@ -2409,7 +2411,7 @@ mod tests { while let Ok(ev) = rx.try_recv() { assert!( - !matches!(ev, AppEvent::CodexOp(AppCommand::Interrupt)), + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), "expected Esc to not send Op::Interrupt while command popup is active" ); } @@ -2445,7 +2447,7 @@ mod tests { while let Ok(ev) = rx.try_recv() { assert!( - !matches!(ev, AppEvent::CodexOp(AppCommand::Interrupt)), + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), "expected Esc to not send Op::Interrupt while typing `/agent`" ); } @@ -2490,7 +2492,7 @@ mod tests { while let Ok(ev) = rx.try_recv() { assert!( - !matches!(ev, AppEvent::CodexOp(AppCommand::Interrupt)), + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), "expected Esc release after dismissing agent picker to not interrupt" ); } @@ -2520,7 +2522,7 @@ mod tests { pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( - matches!(rx.try_recv(), Ok(AppEvent::CodexOp(AppCommand::Interrupt))), + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), "expected Esc to send Op::Interrupt while a task is running" ); } 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 bbbfe1f0b..1b9e8c975 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,10 +32,13 @@ use crate::history_cell; use crate::render::renderable::Renderable; #[cfg(test)] -use codex_protocol::protocol::Op; -use codex_protocol::request_user_input::RequestUserInputAnswer; -use codex_protocol::request_user_input::RequestUserInputEvent; -use codex_protocol::request_user_input::RequestUserInputResponse; +use crate::app_command::AppCommand as Op; +use codex_app_server_protocol::ToolRequestUserInputAnswer; +#[cfg(test)] +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_app_server_protocol::ToolRequestUserInputResponse; use codex_protocol::user_input::TextElement; use unicode_width::UnicodeWidthStr; @@ -122,9 +125,9 @@ impl FooterTip { pub(crate) struct RequestUserInputOverlay { app_event_tx: AppEventSender, - request: RequestUserInputEvent, + request: ToolRequestUserInputParams, // Queue of incoming requests to process after the current one. - queue: VecDeque, + queue: VecDeque, // Reuse the shared chat composer so notes/freeform answers match the // primary input styling and behavior. composer: ChatComposer, @@ -139,7 +142,7 @@ pub(crate) struct RequestUserInputOverlay { impl RequestUserInputOverlay { pub(crate) fn new( - request: RequestUserInputEvent, + request: ToolRequestUserInputParams, app_event_tx: AppEventSender, has_input_focus: bool, enhanced_keys_supported: bool, @@ -179,9 +182,7 @@ impl RequestUserInputOverlay { self.current_idx } - fn current_question( - &self, - ) -> Option<&codex_protocol::request_user_input::RequestUserInputQuestion> { + fn current_question(&self) -> Option<&ToolRequestUserInputQuestion> { self.request.questions.get(self.current_index()) } @@ -586,9 +587,7 @@ impl RequestUserInputOverlay { self.pending_submission_draft = None; } - fn options_len_for_question( - question: &codex_protocol::request_user_input::RequestUserInputQuestion, - ) -> usize { + fn options_len_for_question(question: &ToolRequestUserInputQuestion) -> usize { let options_len = question .options .as_ref() @@ -601,9 +600,7 @@ impl RequestUserInputOverlay { } } - fn other_option_enabled_for_question( - question: &codex_protocol::request_user_input::RequestUserInputQuestion, - ) -> bool { + fn other_option_enabled_for_question(question: &ToolRequestUserInputQuestion) -> bool { question.is_other && question .options @@ -612,7 +609,7 @@ impl RequestUserInputOverlay { } fn option_label_for_index( - question: &codex_protocol::request_user_input::RequestUserInputQuestion, + question: &ToolRequestUserInputQuestion, idx: usize, ) -> Option { let options = question.options.as_ref()?; @@ -753,14 +750,14 @@ impl RequestUserInputOverlay { } answers.insert( question.id.clone(), - RequestUserInputAnswer { + ToolRequestUserInputAnswer { answers: answer_list, }, ); } self.app_event_tx.user_input_answer( self.request.turn_id.clone(), - RequestUserInputResponse { + ToolRequestUserInputResponse { answers: answers.clone(), }, ); @@ -781,8 +778,8 @@ impl RequestUserInputOverlay { let queue_len = self.queue.len(); self.queue - .retain(|queued_request| queued_request.call_id != *call_id); - if self.request.call_id == *call_id { + .retain(|queued_request| queued_request.item_id != *call_id); + if self.request.item_id == *call_id { self.advance_queue_or_complete(); return true; } @@ -1294,8 +1291,8 @@ impl BottomPaneView for RequestUserInputOverlay { fn try_consume_user_input_request( &mut self, - request: RequestUserInputEvent, - ) -> Option { + request: ToolRequestUserInputParams, + ) -> Option { self.queue.push_back(request); None } @@ -1308,12 +1305,9 @@ impl BottomPaneView for RequestUserInputOverlay { #[cfg(test)] mod tests { use super::*; - use crate::app_command::AppCommand; use crate::app_event::AppEvent; use crate::bottom_pane::selection_popup_common::menu_surface_inset; use crate::render::renderable::Renderable; - use codex_protocol::request_user_input::RequestUserInputQuestion; - use codex_protocol::request_user_input::RequestUserInputQuestionOption; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -1341,23 +1335,23 @@ mod tests { ); } - fn question_with_options(id: &str, header: &str) -> RequestUserInputQuestion { - RequestUserInputQuestion { + fn question_with_options(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { id: id.to_string(), header: header.to_string(), question: "Choose an option.".to_string(), is_other: false, is_secret: false, options: Some(vec![ - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Option 1".to_string(), description: "First choice.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Option 2".to_string(), description: "Second choice.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Option 3".to_string(), description: "Third choice.".to_string(), }, @@ -1365,23 +1359,23 @@ mod tests { } } - fn question_with_options_and_other(id: &str, header: &str) -> RequestUserInputQuestion { - RequestUserInputQuestion { + fn question_with_options_and_other(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { id: id.to_string(), header: header.to_string(), question: "Choose an option.".to_string(), is_other: true, is_secret: false, options: Some(vec![ - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Option 1".to_string(), description: "First choice.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Option 2".to_string(), description: "Second choice.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Option 3".to_string(), description: "Third choice.".to_string(), }, @@ -1389,27 +1383,27 @@ mod tests { } } - fn question_with_wrapped_options(id: &str, header: &str) -> RequestUserInputQuestion { - RequestUserInputQuestion { + fn question_with_wrapped_options(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { id: id.to_string(), header: header.to_string(), question: "Choose the next step for this task.".to_string(), is_other: false, is_secret: false, options: Some(vec![ - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Discuss a code change".to_string(), description: "Walk through a plan, then implement it together with careful checks." .to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Run targeted tests".to_string(), description: "Pick the most relevant crate and validate the current behavior first." .to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Review the diff".to_string(), description: "Summarize the changes and highlight the most important risks and gaps." @@ -1419,19 +1413,19 @@ mod tests { } } - fn question_with_very_long_option_text(id: &str, header: &str) -> RequestUserInputQuestion { - RequestUserInputQuestion { + fn question_with_very_long_option_text(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { id: id.to_string(), header: header.to_string(), question: "Choose one option.".to_string(), is_other: false, is_secret: false, options: Some(vec![ - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/unknown (Recommended when triaging long-running background work and status transitions)".to_string(), description: "Keep async job statuses for progress tracking and include enough context for debugging retries, stale workers, and unexpected expiration paths.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Add a short status model".to_string(), description: "Simpler labels with less detail for quick rollouts.".to_string(), }, @@ -1439,8 +1433,8 @@ mod tests { } } - fn question_with_long_scroll_options(id: &str, header: &str) -> RequestUserInputQuestion { - RequestUserInputQuestion { + fn question_with_long_scroll_options(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { id: id.to_string(), header: header.to_string(), question: @@ -1449,19 +1443,19 @@ mod tests { is_other: false, is_secret: false, options: Some(vec![ - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Use Detailed Hint A (Recommended)".to_string(), description: "Select this if you want a deliberately overextended explanatory hint that reads like a miniature specification, including context, rationale, expected behavior, and an explicit statement that this choice is mainly for testing how gracefully the interface wraps, truncates, and preserves readability under unusually verbose helper text conditions.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Use Detailed Hint B".to_string(), description: "Select this if you want an equally verbose but differently phrased guidance block that emphasizes user-facing clarity, spacing tolerance, multiline wrapping, visual hierarchy interactions, and whether long descriptive metadata remains understandable when scanned quickly in a constrained layout where cognitive load is already high.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Use Detailed Hint C".to_string(), description: "Select this when you specifically want to verify that navigating downward will keep the currently highlighted option visible, even when previous options consume many wrapped lines and would otherwise push the selection out of the viewport.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "None of the above".to_string(), description: "Use this only if the previous long-form options do not apply.".to_string(), @@ -1470,8 +1464,8 @@ mod tests { } } - fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion { - RequestUserInputQuestion { + fn question_without_options(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { id: id.to_string(), header: header.to_string(), question: "Share details.".to_string(), @@ -1483,10 +1477,11 @@ mod tests { fn request_event( turn_id: &str, - questions: Vec, - ) -> RequestUserInputEvent { - RequestUserInputEvent { - call_id: "call-1".to_string(), + questions: Vec, + ) -> ToolRequestUserInputParams { + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-1".to_string(), turn_id: turn_id.to_string(), questions, } @@ -1546,13 +1541,15 @@ mod tests { /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); - overlay.try_consume_user_input_request(RequestUserInputEvent { - call_id: "call-2".to_string(), + overlay.try_consume_user_input_request(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-2".to_string(), turn_id: "turn-2".to_string(), questions: vec![question_with_options("q2", "Second")], }); - overlay.try_consume_user_input_request(RequestUserInputEvent { - call_id: "call-3".to_string(), + overlay.try_consume_user_input_request(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-3".to_string(), turn_id: "turn-3".to_string(), questions: vec![question_with_options("q3", "Third")], }); @@ -1567,8 +1564,9 @@ mod tests { fn resolved_request_dismisses_overlay_without_emitting_events() { let (tx, mut rx) = test_sender(); let mut overlay = RequestUserInputOverlay::new( - RequestUserInputEvent { - call_id: "call-1".to_string(), + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-1".to_string(), turn_id: "turn-1".to_string(), questions: vec![question_with_options("q1", "First")], }, @@ -1594,8 +1592,9 @@ mod tests { fn resolved_current_request_advances_to_next_same_turn_prompt() { let (tx, mut rx) = test_sender(); let mut overlay = RequestUserInputOverlay::new( - RequestUserInputEvent { - call_id: "call-1".to_string(), + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-1".to_string(), turn_id: "turn-1".to_string(), questions: vec![question_with_options("q1", "First")], }, @@ -1604,8 +1603,9 @@ mod tests { /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); - overlay.try_consume_user_input_request(RequestUserInputEvent { - call_id: "call-2".to_string(), + overlay.try_consume_user_input_request(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-2".to_string(), turn_id: "turn-1".to_string(), questions: vec![question_with_options("q2", "Second")], }); @@ -1617,7 +1617,7 @@ mod tests { ); assert!(!overlay.done, "newer same-turn prompt should stay pending"); - assert_eq!(overlay.request.call_id, "call-2"); + assert_eq!(overlay.request.item_id, "call-2"); assert_eq!(overlay.request.turn_id, "turn-1"); assert_eq!(overlay.request.questions[0].id, "q2"); assert!( @@ -1630,8 +1630,9 @@ mod tests { fn resolved_queued_request_removes_only_that_prompt() { let (tx, mut rx) = test_sender(); let mut overlay = RequestUserInputOverlay::new( - RequestUserInputEvent { - call_id: "call-1".to_string(), + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-1".to_string(), turn_id: "turn-1".to_string(), questions: vec![question_with_options("q1", "First")], }, @@ -1640,13 +1641,15 @@ mod tests { /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); - overlay.try_consume_user_input_request(RequestUserInputEvent { - call_id: "call-2".to_string(), + overlay.try_consume_user_input_request(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-2".to_string(), turn_id: "turn-1".to_string(), questions: vec![question_with_options("q2", "Second")], }); - overlay.try_consume_user_input_request(RequestUserInputEvent { - call_id: "call-3".to_string(), + overlay.try_consume_user_input_request(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-3".to_string(), turn_id: "turn-1".to_string(), questions: vec![question_with_options("q3", "Third")], }); @@ -1657,13 +1660,13 @@ mod tests { }) ); - assert_eq!(overlay.request.call_id, "call-1"); + assert_eq!(overlay.request.item_id, "call-1"); assert!( rx.try_recv().is_err(), "dismissing a stale queued request should not emit an event" ); overlay.submit_answers(); - assert_eq!(overlay.request.call_id, "call-3"); + assert_eq!(overlay.request.item_id, "call-3"); assert_eq!(overlay.request.questions[0].id, "q3"); assert!( rx.try_recv().is_ok(), @@ -1689,7 +1692,7 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(AppCommand::UserInputAnswer { id, response, .. }) = event else { + let AppEvent::CodexOp(Op::UserInputAnswer { id, response, .. }) = event else { panic!("expected UserInputAnswer"); }; assert_eq!(id, "turn-1"); @@ -1711,7 +1714,7 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else { + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -1747,19 +1750,19 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else { + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { panic!("expected UserInputAnswer"); }; let mut expected = HashMap::new(); expected.insert( "q1".to_string(), - RequestUserInputAnswer { + ToolRequestUserInputAnswer { answers: vec!["Option 1".to_string()], }, ); expected.insert( "q2".to_string(), - RequestUserInputAnswer { + ToolRequestUserInputAnswer { answers: vec!["Option 1".to_string()], }, ); @@ -1780,7 +1783,7 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else { + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -2030,7 +2033,7 @@ mod tests { overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else { + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -2330,7 +2333,7 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else { + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -2355,7 +2358,7 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else { + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -2398,7 +2401,7 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else { + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -2434,7 +2437,7 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else { + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -2519,7 +2522,7 @@ mod tests { overlay.submit_answers(); let event = rx.try_recv().expect("expected AppEvent"); - let AppEvent::CodexOp(AppCommand::UserInputAnswer { response, .. }) = event else { + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { panic!("expected UserInputAnswer"); }; let answer = response.answers.get("q1").expect("answer missing"); @@ -2852,30 +2855,30 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event( "turn-1", - vec![RequestUserInputQuestion { + vec![ToolRequestUserInputQuestion { id: "q1".to_string(), header: "Next Step".to_string(), question: "What would you like to do next?".to_string(), is_other: false, is_secret: false, options: Some(vec![ - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Discuss a code change (Recommended)".to_string(), description: "Walk through a plan and edit code together.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Run tests".to_string(), description: "Pick a crate and run its tests.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Review a diff".to_string(), description: "Summarize or review current changes.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Refactor".to_string(), description: "Tighten structure and remove dead code.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Ship it".to_string(), description: "Finalize and open a PR.".to_string(), }, @@ -2904,30 +2907,30 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event( "turn-1", - vec![RequestUserInputQuestion { + vec![ToolRequestUserInputQuestion { id: "q1".to_string(), header: "Next Step".to_string(), question: "What would you like to do next?".to_string(), is_other: false, is_secret: false, options: Some(vec![ - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Discuss a code change (Recommended)".to_string(), description: "Walk through a plan and edit code together.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Run tests".to_string(), description: "Pick a crate and run its tests.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Review a diff".to_string(), description: "Summarize or review current changes.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Refactor".to_string(), description: "Tighten structure and remove dead code.".to_string(), }, - RequestUserInputQuestionOption { + ToolRequestUserInputOption { label: "Ship it".to_string(), description: "Finalize and open a PR.".to_string(), }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bebdbf124..c7cd4e5f6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -42,13 +42,13 @@ use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; -use self::realtime::PendingSteerCompareKey; 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::app_server_approval_conversions::file_update_changes_to_display; +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; @@ -57,6 +57,7 @@ use crate::bottom_pane::StatusSurfacePreviewData; use crate::bottom_pane::StatusSurfacePreviewItem; use crate::bottom_pane::TerminalTitleItem; use crate::bottom_pane::TerminalTitleSetupView; +use crate::diff_model::FileChange; use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME; use crate::legacy_core::config::Config; use crate::legacy_core::config::Constrained; @@ -67,6 +68,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; @@ -77,27 +81,38 @@ 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::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; 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::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::McpServerStatusDetail; 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::RequestId as AppServerRequestId; +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::TextElement as AppServerTextElement; use codex_app_server_protocol::ThreadGoal as AppThreadGoal; use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; use codex_app_server_protocol::ThreadItem; @@ -107,6 +122,7 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnPlanStepStatus; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; use codex_chatgpt::connectors; use codex_config::ConfigLayerStackOrdering; use codex_config::types::ApprovalsReviewer; @@ -126,7 +142,10 @@ use codex_otel::SessionTelemetry; 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; @@ -137,97 +156,14 @@ use codex_protocol::config_types::Settings; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; -use codex_protocol::items::UserMessageItem; use codex_protocol::models::MessagePhase; 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; -use codex_protocol::protocol::McpStartupStatus; -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; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; -use codex_protocol::user_input::UserInput; use codex_terminal_detection::Multiplexer; use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; @@ -356,10 +292,9 @@ 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::McpInvocation; use crate::history_cell::McpToolCallCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::WebSearchCell; @@ -386,9 +321,10 @@ use self::goal_status::GoalStatusState; use self::goal_status::goal_status_indicator_from_app_goal; mod goal_menu; mod interrupts; -mod mcp_startup; 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; @@ -402,12 +338,14 @@ 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::UserMessageDisplay; use crate::streaming::chunking::AdaptiveChunkingPolicy; use crate::streaming::commit_tick::CommitTickScope; use crate::streaming::commit_tick::run_commit_tick; @@ -415,6 +353,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; @@ -422,7 +361,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; @@ -496,6 +434,20 @@ fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool { .all(|parsed| !matches!(parsed, ParsedCommand::Unknown { .. })) } +fn command_execution_command_and_parsed( + command: &str, + command_actions: &[codex_app_server_protocol::CommandAction], +) -> (Vec, Vec) { + ( + split_command_string(command), + command_actions + .iter() + .cloned() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(), + ) +} + const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; @@ -652,18 +604,6 @@ enum RateLimitErrorKind { Generic, } -#[cfg(test)] -fn core_rate_limit_error_kind(info: &CoreCodexErrorInfo) -> Option { - match info { - CoreCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded), - CoreCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit), - CoreCodexErrorInfo::ResponseTooManyFailedAttempts { - http_status_code: Some(429), - } => Some(RateLimitErrorKind::Generic), - _ => None, - } -} - fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option { match info { AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded), @@ -675,11 +615,6 @@ fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option bool { - matches!(info, CoreCodexErrorInfo::CyberPolicy) -} - fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool { matches!(info, AppServerCodexErrorInfo::CyberPolicy) } @@ -864,7 +799,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, @@ -881,9 +816,10 @@ pub(crate) struct ChatWidget { agent_turn_running: bool, /// Tracks per-server MCP startup state while startup is in progress. /// - /// The map is `Some(_)` from the first `McpStartupUpdate` until `McpStartupComplete`, and the - /// bottom pane is treated as "running" while this is populated, even if no agent turn is - /// currently executing. + /// The map is `Some(_)` from the first startup status update until the + /// app-server-backed startup round settles, and the bottom pane is treated + /// as "running" while this is populated, even if no agent turn is currently + /// executing. mcp_startup_status: Option>, /// Expected MCP servers for the current startup round, seeded from enabled local config. mcp_startup_expected_servers: Option>, @@ -1033,7 +969,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. @@ -1068,25 +1004,13 @@ pub(crate) struct ChatWidget { goal_status_active_turn_started_at: Option, external_editor_state: ExternalEditorState, realtime_conversation: RealtimeConversationUiState, - last_rendered_user_message_event: Option, + last_rendered_user_message_display: Option, 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, } @@ -1303,6 +1227,10 @@ fn append_text_with_rebased_elements( })); } +fn app_server_text_elements(elements: &[TextElement]) -> Vec { + elements.iter().cloned().map(Into::into).collect() +} + fn build_placeholder_mapping( local_images: Vec, next_label: &mut usize, @@ -1515,14 +1443,14 @@ fn user_message_preview_text( } } -fn user_message_event_for_display( +fn user_message_display_for_history( message: UserMessage, history_record: &UserMessageHistoryRecord, -) -> UserMessageEvent { +) -> UserMessageDisplay { let message = user_message_for_restore(message, history_record); - UserMessageEvent { + UserMessageDisplay { message: message.text, - images: Some(message.remote_image_urls), + remote_image_urls: message.remote_image_urls, local_images: message .local_images .into_iter() @@ -1603,6 +1531,12 @@ enum PlanModeNudgeScope { Thread(ThreadId), } +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum TurnAbortReason { + Interrupted, + BudgetLimited, +} + /// Returns whether `text` contains the standalone word `plan`. /// /// This intentionally mirrors the App suggestion heuristic instead of trying to infer broader @@ -1632,95 +1566,6 @@ 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, - active_permission_profile: session.active_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(), - text: entry.text, - } -} - -fn hook_run_summary_from_notification( - run: codex_app_server_protocol::HookRunSummary, -) -> codex_protocol::protocol::HookRunSummary { - codex_protocol::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(), - source_path: run.source_path, - source: run.source.to_core(), - display_order: run.display_order, - status: run.status.to_core(), - status_message: run.status_message, - started_at: run.started_at, - completed_at: run.completed_at, - duration_ms: run.duration_ms, - entries: run - .entries - .into_iter() - .map(hook_output_entry_from_notification) - .collect(), - } -} - -fn hook_started_event_from_notification( - notification: codex_app_server_protocol::HookStartedNotification, -) -> codex_protocol::protocol::HookStartedEvent { - codex_protocol::protocol::HookStartedEvent { - turn_id: notification.turn_id, - run: hook_run_summary_from_notification(notification.run), - } -} - -fn hook_completed_event_from_notification( - notification: codex_app_server_protocol::HookCompletedNotification, -) -> codex_protocol::protocol::HookCompletedEvent { - codex_protocol::protocol::HookCompletedEvent { - turn_id: notification.turn_id, - run: hook_run_summary_from_notification(notification.run), - } -} - -fn app_server_request_id_to_mcp_request_id( - request_id: &codex_app_server_protocol::RequestId, -) -> codex_protocol::mcp::RequestId { - match request_id { - codex_app_server_protocol::RequestId::String(value) => { - codex_protocol::mcp::RequestId::String(value.clone()) - } - codex_app_server_protocol::RequestId::Integer(value) => { - codex_protocol::mcp::RequestId::Integer(*value) - } - } -} - fn exec_approval_request_from_params( params: CommandExecutionRequestApprovalParams, fallback_cwd: &AbsolutePathBuf, @@ -1734,58 +1579,13 @@ fn exec_approval_request_from_params( .unwrap_or_default(), cwd: params.cwd.unwrap_or_else(|| fallback_cwd.clone()), reason: params.reason, - network_approval_context: params - .network_approval_context - .map(network_approval_context_to_core), - additional_permissions: params.additional_permissions.map(Into::into), + network_approval_context: params.network_approval_context, + additional_permissions: params.additional_permissions, turn_id: params.turn_id, approval_id: params.approval_id, - proposed_execpolicy_amendment: params - .proposed_execpolicy_amendment - .map(codex_app_server_protocol::ExecPolicyAmendment::into_core), - proposed_network_policy_amendments: params.proposed_network_policy_amendments.map( - |amendments| { - amendments - .into_iter() - .map(codex_app_server_protocol::NetworkPolicyAmendment::into_core) - .collect() - }, - ), - available_decisions: params.available_decisions.map(|decisions| { - decisions - .into_iter() - .map(|decision| 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 - } - }) - .collect() - }), - parsed_cmd: params - .command_actions - .unwrap_or_default() - .into_iter() - .map(codex_app_server_protocol::CommandAction::into_core) - .collect(), + proposed_execpolicy_amendment: params.proposed_execpolicy_amendment, + proposed_network_policy_amendments: params.proposed_network_policy_amendments, + available_decisions: params.available_decisions, } } @@ -1801,92 +1601,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 { @@ -1899,35 +1613,6 @@ fn request_permissions_from_params( } } -fn request_user_input_from_params(params: ToolRequestUserInputParams) -> RequestUserInputEvent { - RequestUserInputEvent { - turn_id: params.turn_id, - call_id: params.item_id, - questions: params - .questions - .into_iter() - .map( - |question| codex_protocol::request_user_input::RequestUserInputQuestion { - id: question.id, - header: question.header, - question: question.question, - is_other: question.is_other, - is_secret: question.is_secret, - options: question.options.map(|options| { - options - .into_iter() - .map(|option| RequestUserInputQuestionOption { - label: option.label, - description: option.description, - }) - .collect() - }), - }, - ) - .collect(), - } -} - fn token_usage_info_from_app_server(token_usage: ThreadTokenUsage) -> TokenUsageInfo { TokenUsageInfo { total_token_usage: TokenUsage { @@ -1948,25 +1633,6 @@ fn token_usage_info_from_app_server(token_usage: ThreadTokenUsage) -> TokenUsage } } -fn web_search_action_to_core( - action: codex_app_server_protocol::WebSearchAction, -) -> codex_protocol::models::WebSearchAction { - match action { - codex_app_server_protocol::WebSearchAction::Search { query, queries } => { - codex_protocol::models::WebSearchAction::Search { query, queries } - } - codex_app_server_protocol::WebSearchAction::OpenPage { url } => { - codex_protocol::models::WebSearchAction::OpenPage { url } - } - codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => { - codex_protocol::models::WebSearchAction::FindInPage { url, pattern } - } - codex_app_server_protocol::WebSearchAction::Other => { - codex_protocol::models::WebSearchAction::Other - } - } -} - impl ChatWidget { /// Stores or overwrites the cached nickname and role for a collab agent thread. /// @@ -1982,7 +1648,7 @@ impl ChatWidget { ) { self.collab_agent_metadata.insert( thread_id, - CollabAgentMetadata { + AgentMetadata { agent_nickname, agent_role, }, @@ -1990,7 +1656,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() @@ -2137,15 +1803,7 @@ impl ChatWidget { .iter() .any(|item| item == "run-state" || item == "status") }); - let title_uses_activity = self.config.tui_terminal_title.as_ref().is_none_or(|items| { - items - .iter() - .any(|item| item == "activity" || item == "spinner") - }); - if title_uses_status - || (title_uses_activity - && self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing) - { + if title_uses_status { self.refresh_status_surfaces(); } } @@ -2323,18 +1981,9 @@ impl ChatWidget { } // --- Small event handlers --- - #[cfg(test)] - fn on_session_configured(&mut self, event: codex_protocol::protocol::SessionConfiguredEvent) { - self.on_session_configured_with_display_and_fork_parent_title( - event, - SessionConfiguredDisplay::Normal, - /*fork_parent_title*/ None, - ); - } - 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, ) { @@ -2343,65 +1992,67 @@ impl ChatWidget { 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_with_active_profile( - event.permission_profile.clone(), - event.active_permission_profile.clone(), + session.permission_profile.clone(), + session.active_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.permissions.active_permission_profile = - event.active_permission_profile.clone(); + session.active_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,24 +2064,17 @@ 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, show_fast_status, ); self.apply_session_info_cell(session_info_cell); - - #[cfg(test)] - if let Some(messages) = initial_messages { - self.replay_initial_messages(messages); - } } else if self .active_cell .as_ref() @@ -2475,7 +2119,7 @@ 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, ); @@ -2484,7 +2128,7 @@ impl ChatWidget { 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, ); @@ -2494,7 +2138,7 @@ 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, ); @@ -2531,13 +2175,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(); @@ -2615,11 +2259,6 @@ impl ChatWidget { self.request_redraw(); } - #[cfg(test)] - fn on_agent_message(&mut self, message: String) { - self.finalize_completed_assistant_message(Some(&message)); - } - fn on_agent_message_delta(&mut self, delta: String) { self.handle_streaming_delta(delta); } @@ -3036,14 +2675,6 @@ impl ChatWidget { true } - #[cfg(test)] - fn handle_steer_rejected_error(&mut self, codex_error_info: &CoreCodexErrorInfo) -> bool { - matches!( - codex_error_info, - CoreCodexErrorInfo::ActiveTurnNotSteerable { .. } - ) && self.enqueue_rejected_steer() - } - fn handle_app_server_steer_rejected_error( &mut self, codex_error_info: &AppServerCodexErrorInfo, @@ -3155,28 +2786,6 @@ impl ChatWidget { } } - #[cfg(test)] - fn apply_turn_started_context_window(&mut self, model_context_window: Option) { - let info = match self.token_info.take() { - Some(mut info) => { - info.model_context_window = model_context_window; - info - } - None => { - let Some(model_context_window) = model_context_window else { - return; - }; - TokenUsageInfo { - total_token_usage: TokenUsage::default(), - last_token_usage: TokenUsage::default(), - model_context_window: Some(model_context_window), - } - } - }; - - self.apply_token_info(info); - } - fn apply_token_info(&mut self, info: TokenUsageInfo) { let percent = self.context_remaining_percent(&info); let used_tokens = self.context_used_tokens(&info, percent.is_some()); @@ -3247,16 +2856,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![] @@ -3266,12 +2878,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 @@ -3460,13 +3072,6 @@ impl ChatWidget { self.request_redraw(); } - #[cfg(test)] - fn on_core_model_verification(&mut self, verifications: &[CoreModelVerification]) { - if verifications.contains(&CoreModelVerification::TrustedAccessForCyber) { - self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING); - } - } - fn on_app_server_model_verification(&mut self, verifications: &[AppServerModelVerification]) { if verifications.contains(&AppServerModelVerification::TrustedAccessForCyber) { self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING); @@ -3482,9 +3087,7 @@ impl ChatWidget { self.finalize_turn(); let send_pending_steers_immediately = self.submit_pending_steers_after_interrupt; self.submit_pending_steers_after_interrupt = false; - if reason != TurnAbortReason::ReviewEnded - && self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress - { + if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress { if send_pending_steers_immediately { self.add_to_history(history_cell::new_info_event( "Model interrupted to submit steer instructions.".to_owned(), @@ -3497,8 +3100,9 @@ impl ChatWidget { } } - // Core clears pending_input before emitting TurnAborted, so any unacknowledged steers - // still tracked here must be restored locally instead of waiting for a later commit. + // The server has already discarded pending input by the time the + // interrupted turn reaches the UI, so any unacknowledged steers still + // tracked here must be restored locally instead of waiting for a later commit. if send_pending_steers_immediately { let pending_steers = self .pending_steers @@ -3881,7 +3485,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::history_cell::ReviewDecision::Approved, history_cell::ApprovalDecisionActor::Guardian, ) } else if let Some(summary) = guardian_action_summary(&ev.action) { @@ -3901,7 +3505,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::history_cell::ReviewDecision::TimedOut, history_cell::ApprovalDecisionActor::Guardian, ) } else { @@ -3945,7 +3549,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::history_cell::ReviewDecision::Denied, history_cell::ApprovalDecisionActor::Guardian, ) } else { @@ -3982,15 +3586,20 @@ impl ChatWidget { self.request_redraw(); } - fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) { - let ev2 = ev.clone(); + fn on_elicitation_request( + &mut self, + request_id: AppServerRequestId, + params: McpServerElicitationRequestParams, + ) { + let request_id2 = request_id.clone(); + let params2 = params.clone(); self.defer_or_handle( - |q| q.push_elicitation(ev), - |s| s.handle_elicitation_request_now(ev2), + |q| q.push_elicitation(request_id, params), + |s| s.handle_elicitation_request_now(request_id2, params2), ); } - fn on_request_user_input(&mut self, ev: RequestUserInputEvent) { + fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) { let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_user_input(ev), @@ -4006,25 +3615,42 @@ impl ChatWidget { ); } - fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { + fn on_command_execution_started(&mut self, item: ThreadItem) { + let ThreadItem::CommandExecution { + id, + command, + process_id, + source, + command_actions, + .. + } = &item + else { + return; + }; + let (_command, parsed_cmd) = command_execution_command_and_parsed(command, command_actions); self.flush_answer_stream_with_separator(); - if is_unified_exec_source(ev.source) { - self.track_unified_exec_process_begin(&ev); + if is_unified_exec_source(*source) { + if *source == ExecCommandSource::UnifiedExecStartup { + self.track_unified_exec_process_begin(id, process_id.as_deref(), command); + } if !self.bottom_pane.is_task_running() { return; } // Unified exec may be parsed as Unknown; keep the working indicator visible regardless. self.bottom_pane.ensure_status_indicator(); - if !is_standard_tool_call(&ev.parsed_cmd) { + if !is_standard_tool_call(&parsed_cmd) { return; } } - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2)); + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_started(item), + |s| s.handle_command_execution_started_now(item2), + ); } - fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) { - self.track_unified_exec_output_chunk(&ev.call_id, &ev.chunk); + fn on_exec_command_output_delta(&mut self, call_id: &str, delta: &str) { + self.track_unified_exec_output_chunk(call_id, delta.as_bytes()); if !self.bottom_pane.is_task_running() { return; } @@ -4037,13 +3663,13 @@ impl ChatWidget { return; }; - if cell.append_output(&ev.call_id, std::str::from_utf8(&ev.chunk).unwrap_or("")) { + if cell.append_output(call_id, delta) { self.bump_active_cell_revision(); self.request_redraw(); } } - fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) { + fn on_terminal_interaction(&mut self, process_id: String, stdin: String) { if !self.bottom_pane.is_task_running() { return; } @@ -4051,9 +3677,9 @@ impl ChatWidget { let command_display = self .unified_exec_processes .iter() - .find(|process| process.key == ev.process_id) + .find(|process| process.key == process_id) .map(|process| process.command_display.clone()); - if ev.stdin.is_empty() { + if stdin.is_empty() { // Empty stdin means we are polling for background output. // Surface this in the status indicator (single "waiting" surface) instead of // the transcript. Keep the header short so the interrupt hint remains visible. @@ -4068,17 +3694,17 @@ impl ChatWidget { /*details_max_lines*/ 1, ); match &mut self.unified_exec_wait_streak { - Some(wait) if wait.process_id == ev.process_id => { + Some(wait) if wait.process_id == process_id => { wait.update_command_display(command_display); } Some(_) => { self.flush_unified_exec_wait_streak(); self.unified_exec_wait_streak = - Some(UnifiedExecWaitStreak::new(ev.process_id, command_display)); + Some(UnifiedExecWaitStreak::new(process_id, command_display)); } None => { self.unified_exec_wait_streak = - Some(UnifiedExecWaitStreak::new(ev.process_id, command_display)); + Some(UnifiedExecWaitStreak::new(process_id, command_display)); } } self.request_redraw(); @@ -4086,58 +3712,69 @@ impl ChatWidget { if self .unified_exec_wait_streak .as_ref() - .is_some_and(|wait| wait.process_id == ev.process_id) + .is_some_and(|wait| wait.process_id == process_id) { self.flush_unified_exec_wait_streak(); } self.add_to_history(history_cell::new_unified_exec_interaction( command_display, - ev.stdin, + stdin, )); } } - fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { - self.add_to_history(history_cell::new_patch_event( - event.changes, - &self.config.cwd, - )); + fn on_patch_apply_begin(&mut self, changes: HashMap) { + self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd)); } - fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) { + fn on_view_image_tool_call(&mut self, path: AbsolutePathBuf) { self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_view_image_tool_call( - event.path, + path, &self.config.cwd, )); self.request_redraw(); } - fn on_image_generation_begin(&mut self, _event: ImageGenerationBeginEvent) { + fn on_image_generation_begin(&mut self) { self.flush_answer_stream_with_separator(); } - fn on_image_generation_end(&mut self, event: ImageGenerationEndEvent) { + fn on_image_generation_end( + &mut self, + call_id: String, + revised_prompt: Option, + saved_path: Option, + ) { self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_image_generation_call( - event.call_id, - event.revised_prompt, - event.saved_path, + call_id, + revised_prompt, + saved_path, )); self.request_redraw(); } - fn on_patch_apply_end(&mut self, event: codex_protocol::protocol::PatchApplyEndEvent) { - let ev2 = event.clone(); + fn on_file_change_completed(&mut self, item: ThreadItem) { + let item2 = item.clone(); self.defer_or_handle( - |q| q.push_patch_end(event), - |s| s.handle_patch_apply_end_now(ev2), + |q| q.push_item_completed(item), + |s| s.handle_file_change_completed_now(item2), ); } - fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) { - if is_unified_exec_source(ev.source) { - if let Some(process_id) = ev.process_id.as_deref() + fn on_command_execution_completed(&mut self, item: ThreadItem) { + let ThreadItem::CommandExecution { + id, + process_id, + source, + .. + } = &item + else { + return; + }; + if is_unified_exec_source(*source) { + if let Some(process_id) = process_id.as_deref() && self .unified_exec_wait_streak .as_ref() @@ -4145,33 +3782,39 @@ impl ChatWidget { { self.flush_unified_exec_wait_streak(); } - self.track_unified_exec_process_end(&ev); + self.track_unified_exec_process_end(id, process_id.as_deref()); if !self.bottom_pane.is_task_running() { return; } } - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2)); + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_completed(item), + |s| s.handle_command_execution_completed_now(item2), + ); } - fn track_unified_exec_process_begin(&mut self, ev: &ExecCommandBeginEvent) { - if ev.source != ExecCommandSource::UnifiedExecStartup { - return; - } - let key = ev.process_id.clone().unwrap_or(ev.call_id.to_string()); - let command_display = strip_bash_lc_and_escape(&ev.command); + fn track_unified_exec_process_begin( + &mut self, + call_id: &str, + process_id: Option<&str>, + command: &str, + ) { + let key = process_id.unwrap_or(call_id).to_string(); + let command = split_command_string(command); + let command_display = strip_bash_lc_and_escape(&command); if let Some(existing) = self .unified_exec_processes .iter_mut() .find(|process| process.key == key) { - existing.call_id = ev.call_id.clone(); + existing.call_id = call_id.to_string(); existing.command_display = command_display; existing.recent_chunks.clear(); } else { self.unified_exec_processes.push(UnifiedExecProcessSummary { key, - call_id: ev.call_id.clone(), + call_id: call_id.to_string(), command_display, recent_chunks: Vec::new(), }); @@ -4179,8 +3822,8 @@ impl ChatWidget { self.sync_unified_exec_footer(); } - fn track_unified_exec_process_end(&mut self, ev: &ExecCommandEndEvent) { - let key = ev.process_id.clone().unwrap_or(ev.call_id.to_string()); + fn track_unified_exec_process_end(&mut self, call_id: &str, process_id: Option<&str>) { + let key = process_id.unwrap_or(call_id); let before = self.unified_exec_processes.len(); self.unified_exec_processes .retain(|process| process.key != key); @@ -4224,21 +3867,27 @@ impl ChatWidget { } } - fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); + fn on_mcp_tool_call_started(&mut self, item: ThreadItem) { + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_started(item), + |s| s.handle_mcp_tool_call_started_now(item2), + ); } - fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) { - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); + fn on_mcp_tool_call_completed(&mut self, item: ThreadItem) { + let item2 = item.clone(); + self.defer_or_handle( + |q| q.push_item_completed(item), + |s| s.handle_mcp_tool_call_completed_now(item2), + ); } - fn on_web_search_begin(&mut self, ev: WebSearchBeginEvent) { + fn on_web_search_begin(&mut self, call_id: String) { self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_active_web_search_call( - ev.call_id, + call_id, String::new(), self.config.animations, ))); @@ -4246,13 +3895,13 @@ impl ChatWidget { self.request_redraw(); } - fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { + fn on_web_search_end( + &mut self, + call_id: String, + query: String, + action: codex_app_server_protocol::WebSearchAction, + ) { self.flush_answer_stream_with_separator(); - let WebSearchEndEvent { - call_id, - query, - action, - } = ev; let mut handled = false; if let Some(cell) = self .active_cell @@ -4281,213 +3930,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, @@ -4513,33 +3986,22 @@ 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(); } - #[cfg(test)] - fn on_background_event(&mut self, message: String) { - debug!("BackgroundEvent: {message}"); - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane - .set_interrupt_hint_visible(/*visible*/ true); - self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; - self.set_status_header(message); - } - - fn on_hook_started(&mut self, event: codex_protocol::protocol::HookStartedEvent) { + fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) { self.flush_answer_stream_with_separator(); self.flush_completed_hook_output(); match self.active_hook_cell.as_mut() { Some(cell) => { - cell.start_run(event.run); + cell.start_run(run); self.bump_active_cell_revision(); } None => { self.active_hook_cell = Some(history_cell::new_active_hook_cell( - event.run, + run, self.config.animations, )); self.bump_active_cell_revision(); @@ -4548,8 +4010,7 @@ impl ChatWidget { self.request_redraw(); } - fn on_hook_completed(&mut self, event: codex_protocol::protocol::HookCompletedEvent) { - let completed = event.run; + fn on_hook_completed(&mut self, completed: codex_app_server_protocol::HookRunSummary) { let completed_existing_run = self .active_hook_cell .as_mut() @@ -4651,38 +4112,6 @@ impl ChatWidget { self.frame_requester.schedule_frame_in(delay); } - #[cfg(test)] - fn on_undo_started(&mut self, event: UndoStartedEvent) { - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane - .set_interrupt_hint_visible(/*visible*/ false); - let message = event - .message - .unwrap_or_else(|| "Undo in progress...".to_string()); - self.terminal_title_status_kind = TerminalTitleStatusKind::Undoing; - self.set_status_header(message); - } - - #[cfg(test)] - fn on_undo_completed(&mut self, event: UndoCompletedEvent) { - let UndoCompletedEvent { success, message } = event; - self.bottom_pane.hide_status_indicator(); - self.terminal_title_status_kind = TerminalTitleStatusKind::Working; - self.refresh_terminal_title(); - let message = message.unwrap_or_else(|| { - if success { - "Undo completed successfully.".to_string() - } else { - "Undo failed.".to_string() - } - }); - if success { - self.add_info_message(message, /*hint*/ None); - } else { - self.add_error_message(message); - } - } - fn on_stream_error(&mut self, message: String, additional_details: Option) { if self.retry_status_header.is_none() { self.retry_status_header = Some(self.current_status.header.clone()); @@ -4856,7 +4285,7 @@ impl ChatWidget { /// standalone history entry instead of replacing or flushing the unrelated active exploring /// cell. If this method treated every unknown end as "complete the active cell", the UI could /// merge unrelated commands and hide still-running exploring work. - pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { + pub(crate) fn handle_command_execution_completed_now(&mut self, item: ThreadItem) { enum ExecEndTarget { // Normal case: the active exec cell already tracks this call id. ActiveTracked, @@ -4867,13 +4296,36 @@ impl ChatWidget { NewCell, } - let running = self.running_commands.remove(&ev.call_id); - if self.suppressed_exec_calls.remove(&ev.call_id) { + let ThreadItem::CommandExecution { + id, + command, + process_id: _, + source, + command_actions, + aggregated_output, + exit_code, + duration_ms, + .. + } = item + else { + return; + }; + let event_command = split_command_string(&command); + let event_parsed = command_actions + .into_iter() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(); + let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); + let exit_code = exit_code.unwrap_or_default(); + let aggregated_output = aggregated_output.unwrap_or_default(); + + let running = self.running_commands.remove(&id); + if self.suppressed_exec_calls.remove(&id) { return; } let (command, parsed, source) = match running { Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), + None => (event_command, event_parsed, source), }; let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed); let is_unified_exec_interaction = @@ -4881,11 +4333,7 @@ impl ChatWidget { let is_user_shell = source == ExecCommandSource::UserShell; let end_target = match self.active_cell.as_ref() { Some(cell) => match cell.as_any().downcast_ref::() { - Some(exec_cell) - if exec_cell - .iter_calls() - .any(|call| call.call_id == ev.call_id) => - { + Some(exec_cell) if exec_cell.iter_calls().any(|call| call.call_id == id) => { ExecEndTarget::ActiveTracked } Some(exec_cell) if exec_cell.is_active() => { @@ -4900,15 +4348,15 @@ impl ChatWidget { // instead render the interaction-specific content elsewhere in the UI. let output = if is_unified_exec_interaction { CommandOutput { - exit_code: ev.exit_code, + exit_code, formatted_output: String::new(), aggregated_output: String::new(), } } else { CommandOutput { - exit_code: ev.exit_code, - formatted_output: ev.formatted_output.clone(), - aggregated_output: ev.aggregated_output.clone(), + exit_code, + formatted_output: aggregated_output.clone(), + aggregated_output, } }; @@ -4919,8 +4367,8 @@ impl ChatWidget { .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) { - let completed = cell.complete_call(&ev.call_id, output, ev.duration); - debug_assert!(completed, "active exec cell should contain {}", ev.call_id); + let completed = cell.complete_call(&id, output, duration); + debug_assert!(completed, "active exec cell should contain {id}"); if cell.should_flush() { self.flush_active_cell(); } else { @@ -4931,19 +4379,15 @@ impl ChatWidget { } ExecEndTarget::OrphanHistoryWhileActiveExec => { let mut orphan = new_active_exec_command( - ev.call_id.clone(), + id.clone(), command, parsed, source, - ev.interaction_input.clone(), + /*interaction_input*/ None, self.config.animations, ); - let completed = orphan.complete_call(&ev.call_id, output, ev.duration); - debug_assert!( - completed, - "new orphan exec cell should contain {}", - ev.call_id - ); + let completed = orphan.complete_call(&id, output, duration); + debug_assert!(completed, "new orphan exec cell should contain {id}"); self.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(orphan))); @@ -4952,15 +4396,15 @@ impl ChatWidget { ExecEndTarget::NewCell => { self.flush_active_cell(); let mut cell = new_active_exec_command( - ev.call_id.clone(), + id.clone(), command, parsed, source, - ev.interaction_input.clone(), + /*interaction_input*/ None, self.config.animations, ); - let completed = cell.complete_call(&ev.call_id, output, ev.duration); - debug_assert!(completed, "new exec cell should contain {}", ev.call_id); + let completed = cell.complete_call(&id, output, duration); + debug_assert!(completed, "new exec cell should contain {id}"); if cell.should_flush() { self.add_to_history(cell); } else { @@ -4977,14 +4421,14 @@ impl ChatWidget { } } - pub(crate) fn handle_patch_apply_end_now( - &mut self, - event: codex_protocol::protocol::PatchApplyEndEvent, - ) { + pub(crate) fn handle_file_change_completed_now(&mut self, item: ThreadItem) { + let ThreadItem::FileChange { status, .. } = item else { + return; + }; // If the patch was successful, just let the "Edited" block stand. // Otherwise, add a failure block. - if !event.success { - self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); + if matches!(status, codex_app_server_protocol::PatchApplyStatus::Failed) { + self.add_to_history(history_cell::new_patch_apply_failure(String::new())); } // Mark that actual work was done (patch applied) self.had_work_activity = true; @@ -5032,24 +4476,35 @@ impl ChatWidget { }); } - pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) { + pub(crate) fn handle_elicitation_request_now( + &mut self, + request_id: AppServerRequestId, + params: McpServerElicitationRequestParams, + ) { self.flush_answer_stream_with_separator(); self.notify(Notification::ElicitationRequested { - server_name: ev.server_name.clone(), + server_name: params.server_name.clone(), }); let thread_id = self.thread_id.unwrap_or_default(); - if let Some(request) = McpServerElicitationFormRequest::from_event(thread_id, ev.clone()) { + if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + thread_id, + request_id.clone(), + params.clone(), + ) { self.bottom_pane .push_mcp_server_elicitation_request(request); } else { let request = ApprovalRequest::McpElicitation { thread_id, thread_label: None, - server_name: ev.server_name, - request_id: ev.id, - message: ev.request.message().to_string(), + server_name: params.server_name, + request_id, + message: match params.request { + McpServerElicitationRequest::Form { message, .. } + | McpServerElicitationRequest::Url { message, .. } => message, + }, }; self.bottom_pane .push_approval_request(request, &self.config.features); @@ -5072,7 +4527,7 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn handle_request_user_input_now(&mut self, ev: RequestUserInputEvent) { + pub(crate) fn handle_request_user_input_now(&mut self, ev: ToolRequestUserInputParams) { self.flush_answer_stream_with_separator(); let question_count = ev.questions.len(); let summary = Notification::user_input_request_summary(&ev.questions); @@ -5100,25 +4555,32 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { + pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) { + let ThreadItem::CommandExecution { + id, + command, + source, + command_actions, + .. + } = item + else { + return; + }; + let (command, parsed_cmd) = + command_execution_command_and_parsed(&command, &command_actions); // Ensure the status indicator is visible while the command runs. self.bottom_pane.ensure_status_indicator(); - let parsed_cmd = self.annotate_skill_reads_in_parsed_cmd(ev.parsed_cmd.clone()); + let parsed_cmd = self.annotate_skill_reads_in_parsed_cmd(parsed_cmd); self.running_commands.insert( - ev.call_id.clone(), + id.clone(), RunningCommand { - command: ev.command.clone(), + command: command.clone(), parsed_cmd: parsed_cmd.clone(), - source: ev.source, + source, }, ); - let is_wait_interaction = matches!(ev.source, ExecCommandSource::UnifiedExecInteraction) - && ev - .interaction_input - .as_deref() - .map(str::is_empty) - .unwrap_or(true); - let command_display = ev.command.join(" "); + let is_wait_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); + let command_display = command.join(" "); let should_suppress_unified_wait = is_wait_interaction && self .last_unified_wait @@ -5130,20 +4592,19 @@ impl ChatWidget { self.last_unified_wait = None; } if should_suppress_unified_wait { - self.suppressed_exec_calls.insert(ev.call_id); + self.suppressed_exec_calls.insert(id); return; } - let interaction_input = ev.interaction_input.clone(); if let Some(cell) = self .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) && let Some(new_exec) = cell.with_added_call( - ev.call_id.clone(), - ev.command.clone(), + id.clone(), + command.clone(), parsed_cmd.clone(), - ev.source, - interaction_input.clone(), + source, + /*interaction_input*/ None, ) { *cell = new_exec; @@ -5152,11 +4613,11 @@ impl ChatWidget { self.flush_active_cell(); self.active_cell = Some(Box::new(new_active_exec_command( - ev.call_id.clone(), - ev.command.clone(), + id, + command, parsed_cmd, - ev.source, - interaction_input, + source, + /*interaction_input*/ None, self.config.animations, ))); self.bump_active_cell_revision(); @@ -5165,41 +4626,78 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { + pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) { + let ThreadItem::McpToolCall { + id, + server, + tool, + arguments, + .. + } = item + else { + return; + }; self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( - ev.call_id, - ev.invocation, + id, + McpInvocation { + server, + tool, + arguments: Some(arguments), + }, self.config.animations, ))); self.bump_active_cell_revision(); self.request_redraw(); } - pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { + + pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItem) { self.flush_answer_stream_with_separator(); - let McpToolCallEndEvent { - call_id, - invocation, - duration, + let ThreadItem::McpToolCall { + id, + server, + tool, + arguments, result, + error, + duration_ms, .. - } = ev; + } = item + else { + return; + }; + let invocation = McpInvocation { + server, + tool, + arguments: Some(arguments), + }; + let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); + let result = match (result, error) { + (_, Some(error)) => Err(error.message), + (Some(result), None) => { + let result = *result; + Ok(codex_protocol::mcp::CallToolResult { + content: result.content, + structured_content: result.structured_content, + is_error: Some(false), + meta: None, + }) + } + (None, None) => Err("MCP tool call completed without a result".to_string()), + }; let extra_cell = match self .active_cell .as_mut() .and_then(|cell| cell.as_any_mut().downcast_mut::()) { - Some(cell) if cell.call_id() == call_id => cell.complete(duration, result), + Some(cell) if cell.call_id() == id => cell.complete(duration, result), _ => { self.flush_active_cell(); - let mut cell = history_cell::new_active_mcp_tool_call( - call_id, - invocation, - self.config.animations, - ); + let mut cell = + history_cell::new_active_mcp_tool_call(id, invocation, self.config.animations); let extra_cell = cell.complete(duration, result); self.active_cell = Some(Box::new(cell)); extra_cell @@ -5214,6 +4712,29 @@ impl ChatWidget { self.had_work_activity = true; } + pub(crate) fn handle_queued_item_started_now(&mut self, item: ThreadItem) { + match item { + item @ ThreadItem::CommandExecution { .. } => { + self.handle_command_execution_started_now(item); + } + item @ ThreadItem::McpToolCall { .. } => { + self.handle_mcp_tool_call_started_now(item); + } + _ => {} + } + } + + pub(crate) fn handle_queued_item_completed_now(&mut self, item: ThreadItem) { + match item { + item @ ThreadItem::CommandExecution { .. } => { + self.handle_command_execution_completed_now(item); + } + item @ ThreadItem::FileChange { .. } => self.handle_file_change_completed_now(item), + item @ ThreadItem::McpToolCall { .. } => self.handle_mcp_tool_call_completed_now(item), + _ => {} + } + } + pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self { Self::new_with_op_target(common, CodexOpTarget::AppEvent) } @@ -5430,7 +4951,7 @@ impl ChatWidget { goal_status_active_turn_started_at: None, external_editor_state: ExternalEditorState::Closed, realtime_conversation: RealtimeConversationUiState::default(), - last_rendered_user_message_event: None, + last_rendered_user_message_display: None, last_non_retry_error: None, }; @@ -5959,9 +5480,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 } @@ -6078,7 +5597,7 @@ impl ChatWidget { for image_url in &remote_image_urls { items.push(UserInput::Image { - image_url: image_url.clone(), + url: image_url.clone(), }); } @@ -6091,7 +5610,7 @@ impl ChatWidget { if !text.is_empty() { items.push(UserInput::Text { text: text.clone(), - text_elements: text_elements.clone(), + text_elements: app_server_text_elements(&text_elements), }); } @@ -6250,7 +5769,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(), @@ -6289,7 +5808,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 { @@ -6324,8 +5843,8 @@ impl ChatWidget { .into_iter() .map(|img| img.path) .collect::>(); - self.last_rendered_user_message_event = - Some(Self::rendered_user_message_event_from_parts( + self.last_rendered_user_message_display = + Some(Self::user_message_display_from_parts( text.clone(), text_elements.clone(), local_image_paths.clone(), @@ -6339,8 +5858,8 @@ impl ChatWidget { )); self.record_visible_user_turn_for_copy(); } else if !remote_image_urls.is_empty() { - self.last_rendered_user_message_event = - Some(Self::rendered_user_message_event_from_parts( + self.last_rendered_user_message_display = + Some(Self::user_message_display_from_parts( String::new(), Vec::new(), Vec::new(), @@ -6454,15 +5973,8 @@ impl ChatWidget { let from_replay = render_source.is_replay(); let replay_kind = render_source.replay_kind(); match item { - ThreadItem::UserMessage { id, content } => { - let user_message = UserMessageItem { - id, - content: content - .into_iter() - .map(codex_app_server_protocol::UserInput::into_core) - .collect(), - }; - self.on_committed_user_message(&user_message, from_replay); + ThreadItem::UserMessage { content, .. } => { + self.on_committed_user_message(&content, from_replay); } ThreadItem::AgentMessage { id, @@ -6509,174 +6021,35 @@ impl ChatWidget { } self.on_agent_reasoning_final(); } - ThreadItem::CommandExecution { - id, - command, - cwd, - process_id, - source, - status, - command_actions, - aggregated_output, - exit_code, - duration_ms, - } => { - if matches!( - status, - codex_app_server_protocol::CommandExecutionStatus::InProgress - ) { - self.on_exec_command_begin(ExecCommandBeginEvent { - call_id: id, - process_id, - turn_id: turn_id.clone(), - command: split_command_string(&command), - cwd, - parsed_cmd: command_actions - .into_iter() - .map(codex_app_server_protocol::CommandAction::into_core) - .collect(), - source: source.to_core(), - interaction_input: None, - }); - } else { - let aggregated_output = aggregated_output.unwrap_or_default(); - self.on_exec_command_end(ExecCommandEndEvent { - call_id: id, - process_id, - turn_id: turn_id.clone(), - command: split_command_string(&command), - cwd, - parsed_cmd: command_actions - .into_iter() - .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_default(), - duration: Duration::from_millis( - duration_ms.unwrap_or_default().max(0) as u64 - ), - formatted_output: aggregated_output, - status: match status { - codex_app_server_protocol::CommandExecutionStatus::Completed => { - codex_protocol::protocol::ExecCommandStatus::Completed - } - codex_app_server_protocol::CommandExecutionStatus::Failed => { - codex_protocol::protocol::ExecCommandStatus::Failed - } - codex_app_server_protocol::CommandExecutionStatus::Declined => { - codex_protocol::protocol::ExecCommandStatus::Declined - } - codex_app_server_protocol::CommandExecutionStatus::InProgress => { - codex_protocol::protocol::ExecCommandStatus::Failed - } - }, - }); - } - } - ThreadItem::FileChange { - id, - changes, - status, - } => { - if !matches!( - status, - codex_app_server_protocol::PatchApplyStatus::InProgress - ) { - self.on_patch_apply_end(codex_protocol::protocol::PatchApplyEndEvent { - call_id: id, - turn_id: turn_id.clone(), - stdout: String::new(), - stderr: String::new(), - success: !matches!( - status, - codex_app_server_protocol::PatchApplyStatus::Failed - ), - 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::Failed => { - codex_protocol::protocol::PatchApplyStatus::Failed - } - codex_app_server_protocol::PatchApplyStatus::Declined => { - codex_protocol::protocol::PatchApplyStatus::Declined - } - codex_app_server_protocol::PatchApplyStatus::InProgress => { - codex_protocol::protocol::PatchApplyStatus::Failed - } - }, - }); - } - } - ThreadItem::McpToolCall { - id, - server, - tool, - arguments, - mcp_app_resource_uri, - result, - error, - duration_ms, + item @ ThreadItem::CommandExecution { + status: codex_app_server_protocol::CommandExecutionStatus::InProgress, .. - } => { - self.on_mcp_tool_call_end(codex_protocol::protocol::McpToolCallEndEvent { - call_id: id, - invocation: codex_protocol::protocol::McpInvocation { - server, - tool, - arguments: Some(arguments), - }, - mcp_app_resource_uri, - duration: Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64), - result: match (result, error) { - (_, Some(error)) => Err(error.message), - (Some(result), None) => { - let result = *result; - Ok(codex_protocol::mcp::CallToolResult { - content: result.content, - structured_content: result.structured_content, - is_error: Some(false), - meta: None, - }) - } - (None, None) => Err("MCP tool call completed without a result".to_string()), - }, - }); - } + } => self.on_command_execution_started(item), + item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item), + ThreadItem::FileChange { + status: codex_app_server_protocol::PatchApplyStatus::InProgress, + .. + } => {} + item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item), + item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item), ThreadItem::WebSearch { id, query, action } => { - self.on_web_search_begin(WebSearchBeginEvent { - call_id: id.clone(), - }); - self.on_web_search_end(WebSearchEndEvent { - call_id: id, + self.on_web_search_begin(id.clone()); + self.on_web_search_end( + id, query, - action: action - .map(web_search_action_to_core) - .unwrap_or(codex_protocol::models::WebSearchAction::Other), - }); + action.unwrap_or(codex_app_server_protocol::WebSearchAction::Other), + ); } - ThreadItem::ImageView { id, path } => { - self.on_view_image_tool_call(ViewImageToolCallEvent { call_id: id, path }); + ThreadItem::ImageView { id: _, path } => { + self.on_view_image_tool_call(path); } ThreadItem::ImageGeneration { id, - status, revised_prompt, - result, saved_path, + .. } => { - self.on_image_generation_end(ImageGenerationEndEvent { - call_id: id, - result, - revised_prompt, - status, - saved_path, - }); + self.on_image_generation_end(id, revised_prompt, saved_path); } ThreadItem::EnteredReviewMode { review, .. } => { if from_replay { @@ -6740,16 +6113,13 @@ impl ChatWidget { ); } ServerRequest::McpServerElicitationRequest { request_id, params } => { - self.on_mcp_server_elicitation_request( - app_server_request_id_to_mcp_request_id(&request_id), - params, - ); + self.on_elicitation_request(request_id, params); } ServerRequest::PermissionsRequestApproval { params, .. } => { self.on_request_permissions(request_permissions_from_params(params)); } ServerRequest::ToolRequestUserInput { params, .. } => { - self.on_request_user_input(request_user_input_from_params(params)); + self.on_request_user_input(params); } ServerRequest::DynamicToolCall { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } @@ -6794,12 +6164,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, @@ -6845,18 +6212,10 @@ impl ChatWidget { } ServerNotification::ReasoningSummaryPartAdded(_) => self.on_reasoning_section_break(), ServerNotification::TerminalInteraction(notification) => { - self.on_terminal_interaction(TerminalInteractionEvent { - call_id: notification.item_id, - process_id: notification.process_id, - stdin: notification.stdin, - }) + self.on_terminal_interaction(notification.process_id, notification.stdin) } ServerNotification::CommandExecutionOutputDelta(notification) => { - self.on_exec_command_output_delta(ExecCommandOutputDeltaEvent { - call_id: notification.item_id, - stream: codex_protocol::protocol::ExecOutputStream::Stdout, - chunk: notification.delta.into_bytes(), - }); + self.on_exec_command_output_delta(¬ification.item_id, ¬ification.delta); } ServerNotification::FileChangeOutputDelta(notification) => { self.on_patch_apply_output_delta(notification.item_id, notification.delta); @@ -6882,10 +6241,10 @@ impl ChatWidget { }) } ServerNotification::HookStarted(notification) => { - self.on_hook_started(hook_started_event_from_notification(notification)); + self.on_hook_started(notification.run); } ServerNotification::HookCompleted(notification) => { - self.on_hook_completed(hook_completed_event_from_notification(notification)); + self.on_hook_completed(notification.run); } ServerNotification::Error(notification) => { if notification.will_retry { @@ -6918,10 +6277,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 @@ -6957,54 +6313,27 @@ impl ChatWidget { } ServerNotification::ThreadRealtimeStarted(notification) => { if !from_replay { - self.on_realtime_conversation_started( - codex_protocol::protocol::RealtimeConversationStartedEvent { - realtime_session_id: notification.realtime_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, - ), - }, - ); + self.on_realtime_item_added(notification); } } 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) => { @@ -7039,46 +6368,10 @@ 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); } - fn on_mcp_server_elicitation_request( - &mut self, - request_id: codex_protocol::mcp::RequestId, - params: codex_app_server_protocol::McpServerElicitationRequestParams, - ) { - let request = codex_protocol::approvals::ElicitationRequestEvent { - turn_id: params.turn_id, - server_name: params.server_name, - id: request_id, - request: match params.request { - codex_app_server_protocol::McpServerElicitationRequest::Form { - meta, - message, - requested_schema, - } => codex_protocol::approvals::ElicitationRequest::Form { - meta, - message, - requested_schema: serde_json::to_value(requested_schema) - .unwrap_or(serde_json::Value::Null), - }, - codex_app_server_protocol::McpServerElicitationRequest::Url { - meta, - message, - url, - elicitation_id, - } => codex_protocol::approvals::ElicitationRequest::Url { - meta, - message, - url, - elicitation_id, - }, - }, - }; - self.on_elicitation_request(request); - } - fn handle_turn_completed_notification( &mut self, notification: TurnCompletedNotification, @@ -7131,60 +6424,16 @@ impl ChatWidget { from_replay: bool, ) { match notification.item { - ThreadItem::CommandExecution { - id, - command, - cwd, - process_id, - source, - command_actions, - .. - } => { - self.on_exec_command_begin(ExecCommandBeginEvent { - call_id: id, - process_id, - turn_id: notification.turn_id, - command: split_command_string(&command), - cwd, - parsed_cmd: command_actions - .into_iter() - .map(codex_app_server_protocol::CommandAction::into_core) - .collect(), - source: source.to_core(), - interaction_input: None, - }); - } - ThreadItem::FileChange { id, changes, .. } => { - self.on_patch_apply_begin(PatchApplyBeginEvent { - call_id: id, - turn_id: notification.turn_id, - auto_approved: false, - changes: file_update_changes_to_core(changes), - }); - } - ThreadItem::McpToolCall { - id, - server, - tool, - arguments, - mcp_app_resource_uri, - .. - } => { - self.on_mcp_tool_call_begin(McpToolCallBeginEvent { - call_id: id, - invocation: codex_protocol::protocol::McpInvocation { - server, - tool, - arguments: Some(arguments), - }, - mcp_app_resource_uri, - }); + item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_started(item), + ThreadItem::FileChange { id: _, changes, .. } => { + self.on_patch_apply_begin(file_update_changes_to_display(changes)); } + item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_started(item), ThreadItem::WebSearch { id, .. } => { - self.on_web_search_begin(WebSearchBeginEvent { call_id: id }); + self.on_web_search_begin(id); } - ThreadItem::ImageGeneration { id, .. } => { - self.on_image_generation_begin(ImageGenerationBeginEvent { call_id: id }); + ThreadItem::ImageGeneration { .. } => { + self.on_image_generation_begin(); } ThreadItem::CollabAgentToolCall { id, @@ -7261,31 +6510,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 } } }), @@ -7299,376 +6548,6 @@ impl ChatWidget { }); } - #[cfg(test)] - fn replay_initial_messages(&mut self, events: Vec) { - for msg in events { - if matches!( - msg, - EventMsg::SessionConfigured(_) | EventMsg::ThreadNameUpdated(_) - ) { - continue; - } - // `id: None` indicates a synthetic/fake id coming from replay. - self.dispatch_event_msg( - /*id*/ None, - msg, - Some(ReplayKind::ResumeInitialMessages), - ); - } - } - - #[cfg(test)] - pub(crate) fn handle_codex_event(&mut self, event: Event) { - let Event { id, msg } = event; - self.dispatch_event_msg(Some(id), msg, /*replay_kind*/ None); - } - - #[cfg(test)] - pub(crate) fn handle_codex_event_replay(&mut self, event: Event) { - let Event { msg, .. } = event; - if matches!(msg, EventMsg::ShutdownComplete) { - return; - } - self.dispatch_event_msg(/*id*/ None, msg, Some(ReplayKind::ThreadSnapshot)); - } - - /// Dispatch a protocol `EventMsg` 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 - /// that must not be used to correlate follow-up actions. - #[cfg(test)] - fn dispatch_event_msg( - &mut self, - id: Option, - msg: EventMsg, - replay_kind: Option, - ) { - let from_replay = replay_kind.is_some(); - let is_resume_initial_replay = - matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)); - let is_stream_error = matches!(&msg, EventMsg::StreamError(_)); - if !is_resume_initial_replay && !is_stream_error { - self.restore_retry_status_header_if_present(); - } - - match msg { - EventMsg::AgentMessageDelta(_) - | EventMsg::PlanDelta(_) - | EventMsg::AgentReasoningDelta(_) - | EventMsg::TerminalInteraction(_) - | EventMsg::PatchApplyUpdated(_) - | EventMsg::ExecCommandOutputDelta(_) => {} - _ => { - tracing::trace!("handle_codex_event: {:?}", msg); - } - } - - 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, - ); - } - // NOTE: All three AgentMessage arms feed `record_agent_markdown` even - // when the message is otherwise not rendered (thread-snapshot replay, - // non-review live messages). This ensures the copy source stays - // populated across replay, resume, and live paths. - EventMsg::AgentMessage(AgentMessageEvent { message, .. }) - if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) - && !self.is_review_mode => - { - if !message.is_empty() { - self.record_agent_markdown(&message); - } - } - EventMsg::AgentMessage(AgentMessageEvent { message, .. }) - if from_replay || self.is_review_mode => - { - if !message.is_empty() { - self.record_agent_markdown(&message); - } - // TODO(ccunningham): stop relying on legacy AgentMessage in review mode, - // including thread-snapshot replay, and forward - // ItemCompleted(TurnItem::AgentMessage(_)) instead. - self.on_agent_message(message) - } - EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { - if !message.is_empty() { - self.record_agent_markdown(&message); - } - } - EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { - self.on_agent_message_delta(delta) - } - EventMsg::PlanDelta(event) => self.on_plan_delta(event.delta), - EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) - | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { - delta, - }) => self.on_agent_reasoning_delta(delta), - EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), - EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { - 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; - self.last_turn_id = Some(turn_id); - if !is_resume_initial_replay { - self.apply_turn_started_context_window(model_context_window); - self.on_task_started(); - } - } - EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message, - duration_ms, - .. - }) => { - self.on_task_complete(last_agent_message, duration_ms, from_replay); - } - EventMsg::TokenCount(ev) => { - self.set_token_info(ev.info); - self.on_rate_limit_snapshot(ev.rate_limits); - } - 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) - } - EventMsg::Error(ErrorEvent { - message, - codex_error_info, - }) => { - if codex_error_info - .as_ref() - .is_some_and(|info| self.handle_steer_rejected_error(info)) - { - } else if codex_error_info - .as_ref() - .is_some_and(is_core_cyber_policy_error) - { - self.on_cyber_policy_error(); - } else if let Some(kind) = codex_error_info - .as_ref() - .and_then(core_rate_limit_error_kind) - { - match kind { - RateLimitErrorKind::ServerOverloaded => { - self.on_server_overloaded_error(message) - } - RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_rate_limit_error(kind, message) - } - } - } else { - self.on_error(message); - } - } - EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev), - EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), - EventMsg::TurnAborted(ev) => match ev.reason { - TurnAbortReason::Interrupted => { - let reason = if ev - .turn_id - .as_deref() - .is_some_and(|turn_id| self.budget_limited_turn_ids.remove(turn_id)) - { - TurnAbortReason::BudgetLimited - } else { - ev.reason - }; - self.on_interrupted_turn(reason); - } - TurnAbortReason::Replaced => { - self.submit_pending_steers_after_interrupt = false; - self.pending_steers.clear(); - self.refresh_pending_input_preview(); - self.on_error("Turn aborted: replaced by a new task".to_owned()) - } - TurnAbortReason::ReviewEnded => { - self.on_interrupted_turn(ev.reason); - } - TurnAbortReason::BudgetLimited => { - if let Some(turn_id) = ev.turn_id.as_deref() { - self.budget_limited_turn_ids.remove(turn_id); - } - self.on_interrupted_turn(ev.reason); - } - }, - EventMsg::PlanUpdate(update) => self.on_plan_update(update), - EventMsg::ExecApprovalRequest(ev) => { - // For replayed events, synthesize an empty id (these should not occur). - self.on_exec_approval_request(id.unwrap_or_default(), ev) - } - EventMsg::ApplyPatchApprovalRequest(ev) => { - self.on_apply_patch_approval_request(id.unwrap_or_default(), ev) - } - EventMsg::ElicitationRequest(ev) => { - self.on_elicitation_request(ev); - } - EventMsg::RequestUserInput(ev) => { - self.on_request_user_input(ev); - } - EventMsg::RequestPermissions(ev) => { - self.on_request_permissions(ev); - } - EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), - EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), - EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), - EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), - EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), - EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev), - EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev), - EventMsg::ImageGenerationBegin(ev) => self.on_image_generation_begin(ev), - EventMsg::ImageGenerationEnd(ev) => self.on_image_generation_end(ev), - EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), - 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 => { - self.refresh_skills_for_current_cwd(/*force_reload*/ true); - } - 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::BackgroundEvent(BackgroundEventEvent { message }) => { - self.on_background_event(message) - } - EventMsg::UndoStarted(ev) => self.on_undo_started(ev), - EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), - EventMsg::StreamError(StreamErrorEvent { - message, - additional_details, - .. - }) => { - if !is_resume_initial_replay { - self.on_stream_error(message, additional_details); - } - } - EventMsg::UserMessage(ev) => { - if from_replay || self.should_render_realtime_user_message_event(&ev) { - self.on_user_message_event(ev); - } - } - EventMsg::EnteredReviewMode(review_request) => { - self.on_entered_review_mode(review_request, 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 { - num_turns: rollback.num_turns, - }); - } - } - 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 { - self.on_committed_user_message(item, from_replay); - } - if let codex_protocol::items::TurnItem::Plan(plan_item) = &item { - self.on_plan_item_completed(plan_item.text.clone()); - } - if let codex_protocol::items::TurnItem::AgentMessage(item) = item { - self.on_agent_message_item_completed(item); - } - } - } - - if !from_replay && self.agent_turn_running { - self.refresh_runtime_metrics(); - } - } - fn enter_review_mode_with_hint(&mut self, hint: String, from_replay: bool) { if self.pre_review_token_info.is_none() { self.pre_review_token_info = Some(self.token_info.clone()); @@ -7694,63 +6573,16 @@ impl ChatWidget { self.request_redraw(); } - #[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. - } - 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"); - }; + fn on_committed_user_message(&mut self, items: &[UserInput], from_replay: bool) { + let display = Self::user_message_display_from_inputs(items); if from_replay { if !self.is_review_mode { - self.on_user_message_event(event); + self.on_user_message_display(display); } return; } - let rendered = Self::rendered_user_message_event_from_event(&event); - let compare_key = Self::pending_steer_compare_key_from_item(item); + let compare_key = Self::pending_steer_compare_key_from_items(items); if self .pending_steers .front() @@ -7758,36 +6590,34 @@ impl ChatWidget { { if let Some(pending) = self.pending_steers.pop_front() { self.refresh_pending_input_preview(); - let pending_event = - user_message_event_for_display(pending.user_message, &pending.history_record); - self.on_user_message_event(pending_event); - } else if self.last_rendered_user_message_event.as_ref() != Some(&rendered) { + let pending_display = + user_message_display_for_history(pending.user_message, &pending.history_record); + self.on_user_message_display(pending_display); + } else if self.last_rendered_user_message_display.as_ref() != Some(&display) { tracing::warn!( "pending steer matched compare key but queue was empty when rendering committed user message" ); - self.on_user_message_event(event); + self.on_user_message_display(display); } } else if !self.is_review_mode - && self.last_rendered_user_message_event.as_ref() != Some(&rendered) + && self.last_rendered_user_message_display.as_ref() != Some(&display) { - self.on_user_message_event(event); + self.on_user_message_display(display); } } - fn on_user_message_event(&mut self, event: UserMessageEvent) { - self.last_rendered_user_message_event = - Some(Self::rendered_user_message_event_from_event(&event)); - let remote_image_urls = event.images.unwrap_or_default(); - if !event.message.trim().is_empty() - || !event.text_elements.is_empty() - || !remote_image_urls.is_empty() + fn on_user_message_display(&mut self, display: UserMessageDisplay) { + self.last_rendered_user_message_display = Some(display.clone()); + if !display.message.trim().is_empty() + || !display.text_elements.is_empty() + || !display.remote_image_urls.is_empty() { self.record_visible_user_turn_for_copy(); self.add_to_history(history_cell::new_user_prompt( - event.message, - event.text_elements, - event.local_images, - remote_image_urls, + display.message, + display.text_elements, + display.local_images, + display.remote_image_urls, )); } @@ -9301,7 +8131,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; @@ -9340,6 +8171,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 @@ -9407,7 +8239,7 @@ impl ChatWidget { })] } else { Self::approval_preset_actions( - preset.approval, + preset_approval, preset.permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, @@ -9417,7 +8249,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, @@ -9425,7 +8257,7 @@ impl ChatWidget { } } else { Self::approval_preset_actions( - preset.approval, + preset_approval, preset.permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, @@ -9457,13 +8289,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, @@ -9574,7 +8406,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::SubmitThreadOp { thread_id, - op: AppCommand::from(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(), @@ -9643,7 +8475,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; } @@ -9719,7 +8552,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(); @@ -9805,7 +8638,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(); @@ -10198,7 +9034,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"); } } @@ -11535,7 +10376,7 @@ 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; } @@ -11548,9 +10389,7 @@ impl ChatWidget { } 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(); } @@ -11561,18 +10400,7 @@ impl ChatWidget { } } - #[cfg(test)] - fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(history_cell::new_mcp_tools_output( - &self.config, - ev.tools, - ev.resources, - ev.resource_templates, - &ev.auth_statuses, - )); - } - - 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(); } @@ -11715,10 +10543,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() @@ -11768,11 +10593,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, @@ -11803,12 +10625,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, @@ -11839,11 +10658,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, }); }), ); @@ -12102,7 +10918,7 @@ impl Notification { } fn user_input_request_summary( - questions: &[codex_protocol::request_user_input::RequestUserInputQuestion], + questions: &[codex_app_server_protocol::ToolRequestUserInputQuestion], ) -> Option { let first_question = questions.first()?; let summary = if first_question.header.trim().is_empty() { @@ -12181,12 +10997,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, diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index f41e3b3a4..0b8dec2f2 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -1,16 +1,15 @@ +//! Queue prompt overlays and deferred tool activity while another interrupt is visible. + use std::collections::VecDeque; use crate::app::app_server_requests::ResolvedAppServerRequest; -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 crate::approval_events::ApplyPatchApprovalRequestEvent; +use crate::approval_events::ExecApprovalRequestEvent; +use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_app_server_protocol::RequestId as AppServerRequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ToolRequestUserInputParams; use codex_protocol::request_permissions::RequestPermissionsEvent; -use codex_protocol::request_user_input::RequestUserInputEvent; use super::ChatWidget; @@ -18,14 +17,14 @@ use super::ChatWidget; pub(crate) enum QueuedInterrupt { ExecApproval(ExecApprovalRequestEvent), ApplyPatchApproval(ApplyPatchApprovalRequestEvent), - Elicitation(ElicitationRequestEvent), + Elicitation { + request_id: AppServerRequestId, + params: McpServerElicitationRequestParams, + }, RequestPermissions(RequestPermissionsEvent), - RequestUserInput(RequestUserInputEvent), - ExecBegin(ExecCommandBeginEvent), - ExecEnd(ExecCommandEndEvent), - McpBegin(McpToolCallBeginEvent), - McpEnd(McpToolCallEndEvent), - PatchEnd(PatchApplyEndEvent), + RequestUserInput(ToolRequestUserInputParams), + ItemStarted(ThreadItem), + ItemCompleted(ThreadItem), } #[derive(Default)] @@ -54,8 +53,13 @@ impl InterruptManager { .push_back(QueuedInterrupt::ApplyPatchApproval(ev)); } - pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) { - self.queue.push_back(QueuedInterrupt::Elicitation(ev)); + pub(crate) fn push_elicitation( + &mut self, + request_id: AppServerRequestId, + params: McpServerElicitationRequestParams, + ) { + self.queue + .push_back(QueuedInterrupt::Elicitation { request_id, params }); } pub(crate) fn push_request_permissions(&mut self, ev: RequestPermissionsEvent) { @@ -63,28 +67,16 @@ impl InterruptManager { .push_back(QueuedInterrupt::RequestPermissions(ev)); } - pub(crate) fn push_user_input(&mut self, ev: RequestUserInputEvent) { + pub(crate) fn push_user_input(&mut self, ev: ToolRequestUserInputParams) { self.queue.push_back(QueuedInterrupt::RequestUserInput(ev)); } - pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { - self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); + pub(crate) fn push_item_started(&mut self, item: ThreadItem) { + self.queue.push_back(QueuedInterrupt::ItemStarted(item)); } - pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) { - self.queue.push_back(QueuedInterrupt::ExecEnd(ev)); - } - - pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) { - self.queue.push_back(QueuedInterrupt::McpBegin(ev)); - } - - pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) { - self.queue.push_back(QueuedInterrupt::McpEnd(ev)); - } - - pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) { - self.queue.push_back(QueuedInterrupt::PatchEnd(ev)); + pub(crate) fn push_item_completed(&mut self, item: ThreadItem) { + self.queue.push_back(QueuedInterrupt::ItemCompleted(item)); } pub(crate) fn remove_resolved_prompt(&mut self, request: &ResolvedAppServerRequest) -> bool { @@ -99,14 +91,15 @@ impl InterruptManager { match q { QueuedInterrupt::ExecApproval(ev) => chat.handle_exec_approval_now(ev), QueuedInterrupt::ApplyPatchApproval(ev) => chat.handle_apply_patch_approval_now(ev), - QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), + QueuedInterrupt::Elicitation { request_id, params } => { + chat.handle_elicitation_request_now(request_id, params); + } QueuedInterrupt::RequestPermissions(ev) => chat.handle_request_permissions_now(ev), QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev), - QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), - QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), - QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), - QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev), - QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev), + QueuedInterrupt::ItemStarted(item) => chat.handle_queued_item_started_now(item), + QueuedInterrupt::ItemCompleted(item) => { + chat.handle_queued_item_completed_now(item); + } } } } @@ -123,11 +116,11 @@ impl QueuedInterrupt { matches!(request, ResolvedAppServerRequest::FileChangeApproval { id } if ev.call_id == id.as_str()) } - QueuedInterrupt::Elicitation(ev) => { + QueuedInterrupt::Elicitation { request_id, params } => { matches!(request, ResolvedAppServerRequest::McpElicitation { server_name, - request_id, - } if ev.server_name == server_name.as_str() && &ev.id == request_id) + request_id: resolved_request_id, + } if params.server_name == server_name.as_str() && request_id == resolved_request_id) } QueuedInterrupt::RequestPermissions(ev) => { matches!(request, ResolvedAppServerRequest::PermissionsApproval { id } @@ -135,31 +128,28 @@ impl QueuedInterrupt { } QueuedInterrupt::RequestUserInput(ev) => { matches!(request, ResolvedAppServerRequest::UserInput { call_id } - if ev.call_id == call_id.as_str()) + if ev.item_id == call_id.as_str()) } - QueuedInterrupt::ExecBegin(_) - | QueuedInterrupt::ExecEnd(_) - | QueuedInterrupt::McpBegin(_) - | QueuedInterrupt::McpEnd(_) - | QueuedInterrupt::PatchEnd(_) => false, + QueuedInterrupt::ItemStarted(_) | QueuedInterrupt::ItemCompleted(_) => false, } } } #[cfg(test)] mod tests { - use codex_protocol::approvals::ExecApprovalRequestEvent; - use codex_protocol::protocol::ExecCommandBeginEvent; - use codex_protocol::protocol::ExecCommandSource; - use codex_protocol::request_user_input::RequestUserInputEvent; + use crate::approval_events::ExecApprovalRequestEvent; + use codex_app_server_protocol::CommandExecutionSource; + use codex_app_server_protocol::CommandExecutionStatus; + use codex_app_server_protocol::ThreadItem; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use super::*; - fn user_input(call_id: &str, turn_id: &str) -> RequestUserInputEvent { - RequestUserInputEvent { - call_id: call_id.to_string(), + fn user_input(call_id: &str, turn_id: &str) -> ToolRequestUserInputParams { + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: call_id.to_string(), turn_id: turn_id.to_string(), questions: Vec::new(), } @@ -178,20 +168,21 @@ mod tests { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: Vec::new(), } } - fn exec_begin(call_id: &str) -> ExecCommandBeginEvent { - ExecCommandBeginEvent { - call_id: call_id.to_string(), - process_id: None, - turn_id: "turn".to_string(), - command: vec!["true".to_string()], + fn command_execution(call_id: &str) -> ThreadItem { + ThreadItem::CommandExecution { + id: call_id.to_string(), + command: "true".to_string(), cwd: AbsolutePathBuf::current_dir().expect("current dir"), - parsed_cmd: Vec::new(), - source: ExecCommandSource::Agent, - interaction_input: None, + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: Vec::new(), + aggregated_output: None, + exit_code: None, + duration_ms: None, } } @@ -211,7 +202,7 @@ mod tests { let Some(QueuedInterrupt::RequestUserInput(remaining)) = manager.queue.front() else { panic!("expected remaining queued user input"); }; - assert_eq!(remaining.call_id, "call-a"); + assert_eq!(remaining.item_id, "call-a"); } #[test] @@ -237,7 +228,7 @@ mod tests { #[test] fn remove_resolved_prompt_keeps_lifecycle_events() { let mut manager = InterruptManager::new(); - manager.push_exec_begin(exec_begin("call")); + manager.push_item_started(command_execution("call")); assert!( !manager.remove_resolved_prompt(&ResolvedAppServerRequest::ExecApproval { @@ -248,7 +239,7 @@ mod tests { assert_eq!(manager.queue.len(), 1); assert!(matches!( manager.queue.front(), - Some(QueuedInterrupt::ExecBegin(_)) + Some(QueuedInterrupt::ItemStarted(_)) )); } } diff --git a/codex-rs/tui/src/chatwidget/mcp_startup.rs b/codex-rs/tui/src/chatwidget/mcp_startup.rs index d2dc0fc72..cf499a455 100644 --- a/codex-rs/tui/src/chatwidget/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/mcp_startup.rs @@ -1,21 +1,24 @@ //! MCP startup state and status handling for the chat widget. //! -//! This module is a mechanical extraction from `chatwidget.rs`: it keeps the -//! buffered startup-round bookkeeping together while preserving the existing -//! event shapes and rendering behavior. +//! 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; -#[cfg(test)] -use codex_protocol::protocol::McpStartupCompleteEvent; -use codex_protocol::protocol::McpStartupStatus; -#[cfg(test)] -use codex_protocol::protocol::McpStartupUpdateEvent; use super::ChatWidget; +#[derive(Debug, Clone)] +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. @@ -169,11 +172,6 @@ impl ChatWidget { 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!( @@ -236,12 +234,6 @@ impl ChatWidget { 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, diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 413e8a007..0d4ae0bc4 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -1,13 +1,12 @@ 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::ThreadRealtimeItemAddedNotification; +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,130 +65,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, - event: &UserMessageEvent, - ) -> bool { - if !self.realtime_conversation.is_live() { - return false; - } - let key = Self::rendered_user_message_event_from_event(event); - self.last_rendered_user_message_event.as_ref() != Some(&key) - } - fn realtime_footer_hint_items() -> Vec<(String, String)> { vec![("/realtime".to_string(), "stop live voice".to_string())] } @@ -232,16 +108,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, - realtime_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 +163,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.realtime_session_id = ev.realtime_session_id; + self.realtime_conversation.realtime_session_id = notification.realtime_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,58 +180,51 @@ 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 { - realtime_session_id, - .. - } => { - self.realtime_conversation.realtime_session_id = Some(realtime_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_item_added( + &mut self, + notification: ThreadRealtimeItemAddedNotification, + ) { + if self.realtime_conversation_uses_webrtc() { + return; + } + if matches!( + notification + .item + .get("type") + .and_then(|value| value.as_str()), + Some("input_audio_buffer.speech_started" | "response.cancelled") + ) { + self.interrupt_realtime_audio_playback(); } } - 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 @@ -408,7 +275,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(); @@ -480,7 +347,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() { diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 904678003..71ef567e3 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; @@ -73,19 +73,19 @@ impl ChatWidget { let items: Vec = self .skills_all .iter() - .map(|skill| { - let core_skill = protocol_skill_to_core(skill); + .filter_map(|skill| { + let core_skill = protocol_skill_to_core(skill)?; let display_name = skill_display_name(&core_skill); let description = skill_description(&core_skill).to_string(); let name = core_skill.name.clone(); let path = core_skill.path_to_skills_md; - SkillsToggleItem { + Some(SkillsToggleItem { name: display_name, skill_name: name, description, enabled: skill.enabled, path, - } + }) }) .collect(); @@ -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))); } @@ -186,12 +186,23 @@ fn enabled_skills_for_mentions(skills: &[ProtocolSkillMetadata]) -> Vec SkillMetadata { - SkillMetadata { +fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> Option { + let scope = serde_json::to_value(skill.scope) + .and_then(serde_json::from_value) + .inspect_err(|err| { + tracing::warn!( + skill_name = %skill.name, + %err, + "Failed to map app-server skill scope" + ); + }) + .ok()?; + + Some(SkillMetadata { name: skill.name.clone(), description: skill.description.clone(), short_description: skill.short_description.clone(), @@ -222,8 +233,8 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata { }), policy: None, path_to_skills_md: skill.path.clone(), - scope: skill.scope, - } + scope, + }) } pub(crate) fn collect_tool_mentions( diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 82f366fbb..237d0019a 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/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap index e353cbef2..4d916a33c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/status_and_layout.rs expression: combined --- - +• Here is the result. diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 6b590d7ea..462b878e5 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -33,7 +33,6 @@ const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN: &str = "[ . ] Action Require pub(super) enum TerminalTitleStatusKind { Working, WaitingForBackgroundTerminal, - Undoing, #[default] Thinking, } @@ -711,7 +710,6 @@ impl ChatWidget { } TerminalTitleStatusKind::Working => "Working".to_string(), TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(), - TerminalTitleStatusKind::Undoing => "Undoing".to_string(), TerminalTitleStatusKind::Thinking => "Thinking".to_string(), } } @@ -748,9 +746,7 @@ impl ChatWidget { return false; } - self.mcp_startup_status.is_some() - || self.bottom_pane.is_task_running() - || self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing + self.mcp_startup_status.is_some() || self.bottom_pane.is_task_running() } pub(super) fn should_animate_terminal_title_spinner(&self) -> bool { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0748cebf1..77717faad 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,20 +1,23 @@ //! 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::*; -pub(super) use crate::app_command::AppCommand; +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::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; @@ -25,6 +28,8 @@ 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::TokenUsage; +pub(super) use crate::token_usage::TokenUsageInfo; pub(super) use crate::tui::FrameRequester; pub(super) use assert_matches::assert_matches; pub(super) use codex_app_server_protocol::AddCreditsNudgeCreditType; @@ -34,16 +39,20 @@ 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 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::ExecPolicyAmendment; pub(super) use codex_app_server_protocol::FileUpdateChange; pub(super) use codex_app_server_protocol::GuardianApprovalReview; pub(super) use codex_app_server_protocol::GuardianApprovalReviewAction as AppServerGuardianApprovalReviewAction; @@ -73,6 +82,7 @@ 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 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 AppServerPatchApplyStatus; pub(super) use codex_app_server_protocol::PatchChangeKind; pub(super) use codex_app_server_protocol::PermissionsRequestApprovalParams as AppServerPermissionsRequestApprovalParams; @@ -85,16 +95,26 @@ 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::ToolRequestUserInputOption; +pub(super) use codex_app_server_protocol::ToolRequestUserInputParams; +pub(super) use codex_app_server_protocol::ToolRequestUserInputQuestion; 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; pub(super) use codex_app_server_protocol::TurnStartedNotification; pub(super) use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +pub(super) use codex_app_server_protocol::UserInput; pub(super) use codex_app_server_protocol::UserInput as AppServerUserInput; pub(super) use codex_app_server_protocol::WarningNotification; pub(super) use codex_config::AppRequirementToml; @@ -116,16 +136,18 @@ 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::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; pub(super) use codex_protocol::config_types::ServiceTier; pub(super) use codex_protocol::config_types::Settings; -pub(super) use codex_protocol::items::AgentMessageContent; -pub(super) use codex_protocol::items::AgentMessageItem; -pub(super) use codex_protocol::items::PlanItem; -pub(super) use codex_protocol::items::TurnItem; -pub(super) use codex_protocol::items::UserMessageItem; pub(super) use codex_protocol::models::FileSystemPermissions; pub(super) use codex_protocol::models::MessagePhase; pub(super) use codex_protocol::models::NetworkPermissions; @@ -139,73 +161,8 @@ 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::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; -pub(super) use codex_protocol::request_user_input::RequestUserInputQuestionOption; pub(super) use codex_protocol::user_input::TextElement; -pub(super) use codex_protocol::user_input::UserInput; pub(super) use codex_terminal_detection::Multiplexer; pub(super) use codex_terminal_detection::TerminalInfo; pub(super) use codex_terminal_detection::TerminalName; @@ -264,7 +221,6 @@ macro_rules! assert_chatwidget_snapshot { mod app_server; mod approval_requests; -mod background_events; mod composer_submission; mod exec_flow; mod goal_menu; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 9c2e1c83d..13b86e2af 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 3a5720784..93e2a38c1 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -23,12 +23,8 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-short".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); + handle_exec_approval_request(&mut chat, "sub-short", ev); let proposed_cells = drain_insert_history(&mut rx); assert!( @@ -126,21 +122,23 @@ 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!( request.additional_permissions, - Some(codex_protocol::models::AdditionalPermissionProfile { - network: Some(NetworkPermissions { + Some(AppServerAdditionalPermissionProfile { + network: Some(AppServerAdditionalNetworkPermissions { enabled: Some(true), }), - file_system: Some(FileSystemPermissions::from_read_write_roots( - Some(vec![read_path]), - Some(vec![write_path]), - )), + file_system: Some(AppServerAdditionalFileSystemPermissions { + read: Some(vec![read_path]), + write: Some(vec![write_path]), + glob_scan_max_depth: None, + entries: None, + }), }) ); } @@ -192,9 +190,10 @@ fn app_server_request_permissions_preserves_file_system_permissions() { async fn exec_approval_uses_approval_id_when_present() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "sub-short".into(), - msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + handle_exec_approval_request( + &mut chat, + "sub-short", + ExecApprovalRequestEvent { call_id: "call-parent".into(), approval_id: Some("approval-subcommand".into()), turn_id: "turn-short".into(), @@ -208,21 +207,23 @@ async fn exec_approval_uses_approval_id_when_present() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], - }), - }); + }, + ); chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); let mut found = false; while let Ok(app_ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ExecApproval { id, decision, .. }, + op: Op::ExecApproval { id, decision, .. }, .. } = 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; } @@ -248,12 +249,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-multi".into(), - msg: EventMsg::ExecApprovalRequest(ev_multi), - }); + handle_exec_approval_request(&mut chat, "sub-multi", ev_multi); let proposed_multi = drain_insert_history(&mut rx); assert!( proposed_multi.is_empty(), @@ -301,12 +298,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-long".into(), - msg: EventMsg::ExecApprovalRequest(ev_long), - }); + handle_exec_approval_request(&mut chat, "sub-long", ev_long); let proposed_long = drain_insert_history(&mut rx); assert!( proposed_long.is_empty(), diff --git a/codex-rs/tui/src/chatwidget/tests/background_events.rs b/codex-rs/tui/src/chatwidget/tests/background_events.rs deleted file mode 100644 index 1aa09557d..000000000 --- a/codex-rs/tui/src/chatwidget/tests/background_events.rs +++ /dev/null @@ -1,18 +0,0 @@ -use super::*; -use pretty_assertions::assert_eq; - -#[tokio::test] -async fn background_event_updates_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.handle_codex_event(Event { - id: "bg-1".into(), - msg: EventMsg::BackgroundEvent(BackgroundEventEvent { - message: "Waiting for `vim`".to_string(), - }), - }); - - assert!(chat.bottom_pane.status_indicator_visible()); - assert_eq!(chat.current_status.header, "Waiting for `vim`"); - assert!(drain_insert_history(&mut rx).is_empty()); -} diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index e2ac788da..9303ef7aa 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,9 +14,10 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -19,17 +27,14 @@ async fn submission_preserves_text_elements_and_local_images() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); drain_insert_history(&mut rx); let placeholder = "[Image #1]"; @@ -59,7 +64,7 @@ async fn submission_preserves_text_elements_and_local_images() { items[1], UserInput::Text { text: text.clone(), - text_elements: text_elements.clone(), + text_elements: text_elements.clone().into_iter().map(Into::into).collect(), } ); @@ -92,29 +97,31 @@ 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 { - session_id: conversation_id, + } + .into(); + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -124,17 +131,14 @@ async fn submission_includes_configured_permission_profile() { permission_profile: expected_permission_profile.clone(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); drain_insert_history(&mut rx); chat.bottom_pane.set_composer_text( @@ -150,7 +154,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] @@ -159,13 +163,15 @@ 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 { - session_id: conversation_id, + let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Unrestricted, + } + .into(); + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -175,17 +181,14 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { permission_profile: expected_permission_profile.clone(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); drain_insert_history(&mut rx); chat.bottom_pane @@ -198,7 +201,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] @@ -207,9 +210,10 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -219,17 +223,14 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); drain_insert_history(&mut rx); let remote_url = "https://example.com/remote.png".to_string(); @@ -256,7 +257,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi assert_eq!( items[0], UserInput::Image { - image_url: remote_url.clone(), + url: remote_url.clone(), } ); assert_eq!( @@ -269,7 +270,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi items[2], UserInput::Text { text: text.clone(), - text_elements: text_elements.clone(), + text_elements: text_elements.clone().into_iter().map(Into::into).collect(), } ); assert_eq!(text_elements[0].placeholder(&text), Some("[Image #2]")); @@ -303,9 +304,10 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -315,17 +317,14 @@ async fn enter_with_only_remote_images_submits_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); drain_insert_history(&mut rx); let remote_url = "https://example.com/remote-only.png".to_string(); @@ -341,7 +340,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { assert_eq!( items, vec![UserInput::Image { - image_url: remote_url.clone(), + url: remote_url.clone(), }] ); assert_eq!(summary, None); @@ -369,9 +368,10 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -381,17 +381,14 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); drain_insert_history(&mut rx); let remote_url = "https://example.com/remote-only.png".to_string(); @@ -410,9 +407,10 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -422,17 +420,14 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); drain_insert_history(&mut rx); let remote_url = "https://example.com/remote-only.png".to_string(); @@ -451,9 +446,10 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -463,17 +459,14 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); drain_insert_history(&mut rx); let remote_url = "https://example.com/remote-only.png".to_string(); @@ -495,9 +488,10 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -507,17 +501,14 @@ async fn submission_prefers_selected_duplicate_skill_path() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); drain_insert_history(&mut rx); let repo_skill_path = test_path_buf("/tmp/repo/figma/SKILL.md").abs(); @@ -531,7 +522,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(), @@ -541,7 +532,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(), }, ])); @@ -738,15 +729,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { ); chat.refresh_pending_input_preview(); - chat.handle_codex_event(Event { - id: "interrupt".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_interrupted(&mut chat, "turn-1"); assert_eq!(chat.bottom_pane.composer_text(), "Implement the plan."); assert!(chat.queued_user_messages.is_empty()); @@ -1129,15 +1112,15 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { } #[test] -fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape() { +fn user_message_display_from_inputs_matches_flattened_user_message_shape() { let local_image = PathBuf::from("/tmp/local.png"); - let rendered = ChatWidget::rendered_user_message_event_from_inputs(&[ + let rendered = ChatWidget::user_message_display_from_inputs(&[ UserInput::Text { text: "hello ".to_string(), - text_elements: vec![TextElement::new((0..5).into(), /*placeholder*/ None)], + text_elements: vec![TextElement::new((0..5).into(), /*placeholder*/ None).into()], }, UserInput::Image { - image_url: "https://example.com/remote.png".to_string(), + url: "https://example.com/remote.png".to_string(), }, UserInput::LocalImage { path: local_image.clone(), @@ -1152,13 +1135,13 @@ fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape( }, UserInput::Text { text: "world".to_string(), - text_elements: vec![TextElement::new((0..5).into(), Some("planet".to_string()))], + text_elements: vec![TextElement::new((0..5).into(), Some("planet".to_string())).into()], }, ]); assert_eq!( rendered, - ChatWidget::rendered_user_message_event_from_parts( + ChatWidget::user_message_display_from_parts( "hello world".to_string(), vec![ TextElement::new((0..5).into(), Some("hello".to_string())), @@ -1184,16 +1167,8 @@ async fn interrupt_restores_queued_messages_into_composer() { .push_back(UserMessage::from("second queued".to_string()).into()); chat.refresh_pending_input_preview(); - // 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 { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - completed_at: None, - duration_ms: None, - }), - }); + // Deliver an interrupted turn notification as if Esc was pressed. + handle_turn_interrupted(&mut chat, "turn-1"); // Composer should now contain the queued messages joined by newlines, in order. assert_eq!( @@ -1226,15 +1201,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { .push_back(UserMessage::from("second queued".to_string()).into()); chat.refresh_pending_input_preview(); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_interrupted(&mut chat, "turn-1"); assert_eq!( chat.bottom_pane.composer_text(), diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index ec4787248..c9789be14 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -20,12 +20,8 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-short".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); + handle_exec_approval_request(&mut chat, "sub-short", ev); let proposed_cells = drain_insert_history(&mut rx); assert!( @@ -89,9 +85,10 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() { async fn exec_approval_uses_approval_id_when_present() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "sub-short".into(), - msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + handle_exec_approval_request( + &mut chat, + "sub-short", + ExecApprovalRequestEvent { call_id: "call-parent".into(), approval_id: Some("approval-subcommand".into()), turn_id: "turn-short".into(), @@ -105,21 +102,23 @@ async fn exec_approval_uses_approval_id_when_present() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], - }), - }); + }, + ); chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); let mut found = false; while let Ok(app_ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::ExecApproval { id, decision, .. }, + op: Op::ExecApproval { id, decision, .. }, .. } = 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; } @@ -146,12 +145,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-multi".into(), - msg: EventMsg::ExecApprovalRequest(ev_multi), - }); + handle_exec_approval_request(&mut chat, "sub-multi", ev_multi); let proposed_multi = drain_insert_history(&mut rx); assert!( proposed_multi.is_empty(), @@ -201,12 +196,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-long".into(), - msg: EventMsg::ExecApprovalRequest(ev_long), - }); + handle_exec_approval_request(&mut chat, "sub-long", ev_long); let proposed_long = drain_insert_history(&mut rx); assert!( proposed_long.is_empty(), @@ -356,28 +347,26 @@ async fn exec_end_without_begin_uses_event_command() { "-lc".to_string(), "echo orphaned".to_string(), ]; - let parsed_cmd = codex_shell_command::parse_command::parse_command(&command); - let cwd = AbsolutePathBuf::current_dir().expect("current dir"); - chat.handle_codex_event(Event { - id: "call-orphan".to_string(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "call-orphan".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, + let command_actions = codex_shell_command::parse_command::parse_command(&command) + .into_iter() + .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) + .collect(); + let cwd = chat.config.cwd.clone(); + handle_exec_end( + &mut chat, + AppServerThreadItem::CommandExecution { + id: "call-orphan".to_string(), + command: codex_shell_command::parse_command::shlex_join(&command), cwd, - parsed_cmd, + process_id: None, source: ExecCommandSource::Agent, - interaction_input: None, - stdout: "done".to_string(), - stderr: String::new(), - aggregated_output: "done".to_string(), - exit_code: 0, - duration: std::time::Duration::from_millis(5), - formatted_output: "done".to_string(), - status: CoreExecCommandStatus::Completed, - }), - }); + status: AppServerCommandExecutionStatus::Completed, + command_actions, + aggregated_output: Some("done".to_string()), + exit_code: Some(0), + duration_ms: Some(5), + }, + ); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); @@ -618,14 +607,7 @@ async fn unified_exec_interaction_after_task_complete_is_suppressed() { /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false, ); - chat.handle_codex_event(Event { - id: "call-1".to_string(), - msg: EventMsg::TerminalInteraction(TerminalInteractionEvent { - call_id: "call-1".to_string(), - process_id: "proc-1".to_string(), - stdin: "ls\n".to_string(), - }), - }); + terminal_interaction(&mut chat, "call-1", "proc-1", "ls\n"); let cells = drain_insert_history(&mut rx); assert!( @@ -637,30 +619,13 @@ async fn unified_exec_interaction_after_task_complete_is_suppressed() { #[tokio::test] async fn unified_exec_wait_after_final_agent_message_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); begin_unified_exec_startup(&mut chat, "call-wait", "proc-1", "cargo test -p codex-core"); terminal_interaction(&mut chat, "call-wait-stdin", "proc-1", ""); complete_assistant_message(&mut chat, "msg-1", "Final response.", /*phase*/ None); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Final response.".into()), - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); let cells = drain_insert_history(&mut rx); let combined = cells @@ -673,15 +638,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { #[tokio::test] async fn unified_exec_wait_before_streamed_agent_message_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); begin_unified_exec_startup( &mut chat, @@ -691,22 +648,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { ); terminal_interaction(&mut chat, "call-wait-stream-stdin", "proc-1", ""); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "Streaming response.".into(), - }), - }); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_agent_message_delta(&mut chat, "Streaming response."); + handle_turn_completed(&mut chat, "turn-wait-1", /*duration_ms*/ None); let cells = drain_insert_history(&mut rx); let combined = cells @@ -719,15 +662,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { #[tokio::test] async fn final_worked_for_uses_cumulative_turn_duration_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); let exec = begin_exec_with_source( &mut chat, @@ -743,16 +678,7 @@ async fn final_worked_for_uses_cumulative_turn_duration_snapshot() { "Final response.", Some(MessagePhase::FinalAnswer), ); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Final response.".to_string()), - completed_at: None, - duration_ms: Some(125_000), - time_to_first_token_ms: None, - }), - }); + handle_turn_completed(&mut chat, "turn-1", Some(125_000)); let cells = drain_insert_history(&mut rx); let combined = cells @@ -777,11 +703,7 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { recent_chunks: Vec::new(), }); - chat.on_terminal_interaction(TerminalInteractionEvent { - call_id: "call-1".to_string(), - process_id: "proc-1".to_string(), - stdin: String::new(), - }); + terminal_interaction(&mut chat, "call-1", "proc-1", ""); assert!(chat.active_cell.is_none()); assert_eq!( @@ -815,16 +737,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { assert_eq!(status.header(), "Waiting for background terminal"); assert_eq!(status.details(), Some("just fix")); - chat.handle_codex_event(Event { - id: "turn-wait-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_turn_completed(&mut chat, "turn-wait-3", /*duration_ms*/ None); let cells = drain_insert_history(&mut rx); let combined = cells @@ -896,16 +809,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { .collect::(); assert_chatwidget_snapshot!("unified_exec_non_empty_then_empty_active", active_combined); - chat.handle_codex_event(Event { - id: "turn-wait-3".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); let post_cells = drain_insert_history(&mut rx); let mut combined = pre_cells @@ -928,13 +832,7 @@ async fn view_image_tool_call_adds_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let image_path = chat.config.cwd.join("example.png"); - chat.handle_codex_event(Event { - id: "sub-image".into(), - msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent { - call_id: "call-image".into(), - path: image_path, - }), - }); + handle_view_image_tool_call(&mut chat, "call-image", image_path); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected a single history cell"); @@ -946,16 +844,12 @@ async fn view_image_tool_call_adds_history_cell() { async fn image_generation_call_adds_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "sub-image-generation".into(), - msg: EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { - call_id: "call-image-generation".into(), - status: "completed".into(), - revised_prompt: Some("A tiny blue square".into()), - result: "Zm9v".into(), - saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()), - }), - }); + handle_image_generation_end( + &mut chat, + "call-image-generation", + Some("A tiny blue square".into()), + Some(test_path_buf("/tmp/ig-1.png").abs()), + ); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected a single history cell"); @@ -1049,9 +943,10 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -1061,28 +956,17 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); drain_insert_history(&mut rx); while op_rx.try_recv().is_ok() {} - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); chat.bottom_pane .set_composer_text("!echo hi".to_string(), Vec::new(), Vec::new()); @@ -1103,15 +987,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { async fn user_message_during_user_shell_command_is_queued_not_steered() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); let begin = begin_exec_with_source( &mut chat, "user-shell-sleep", @@ -1128,16 +1004,13 @@ async fn user_message_during_user_shell_command_is_queued_not_steered() { assert_eq!(chat.queued_user_message_texts(), vec!["hi".to_string()]); end_exec(&mut chat, begin, "", "", /*exit_code*/ 0); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("done".to_string()), - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + complete_assistant_message( + &mut chat, + "msg-done", + "done", + Some(MessagePhase::FinalAnswer), + ); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( @@ -1174,7 +1047,7 @@ async fn disabled_slash_command_while_task_running_snapshot() { // // Snapshot test: command approval modal // -// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal +// Synthesizes an exec approval request to trigger the approval modal // and snapshots the visual output using the ratatui TestBackend. #[tokio::test] async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { @@ -1184,7 +1057,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(), @@ -1196,20 +1069,14 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { "this is a test reason such as one that would be produced by the model".into(), ), network_approval_context: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello".into(), - "world".into(), - ])), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment { + command: vec!["echo".into(), "hello".into(), "world".into()], + }), proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-approve".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); + handle_exec_approval_request(&mut chat, "sub-approve", ev); // Render to a fixed-size test terminal and snapshot. // Call desired_height first and use that exact height for rendering. let width = 100; @@ -1247,7 +1114,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(), @@ -1257,20 +1124,14 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, network_approval_context: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello".into(), - "world".into(), - ])), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment { + command: vec!["echo".into(), "hello".into(), "world".into()], + }), proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-approve-noreason".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); + handle_exec_approval_request(&mut chat, "sub-approve-noreason", ev); let width = 100; let height = chat.desired_height(width); @@ -1297,7 +1158,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]; @@ -1309,16 +1170,12 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() cwd: AbsolutePathBuf::current_dir().expect("current dir"), reason: None, network_approval_context: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment { command }), proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-approve-multiline-trunc".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); + handle_exec_approval_request(&mut chat, "sub-approve-multiline-trunc", ev); let width = 100; let height = chat.desired_height(width); @@ -1345,7 +1202,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(); @@ -1362,10 +1219,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { reason: Some("The model wants to apply changes".into()), grant_root: Some(PathBuf::from("/tmp")), }; - chat.handle_codex_event(Event { - id: "sub-approve-patch".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ev), - }); + handle_apply_patch_approval_request(&mut chat, "sub-approve-patch", ev); // Render at the widget's desired height and snapshot. let height = chat.desired_height(/*width*/ 80); @@ -1390,15 +1244,7 @@ async fn interrupt_preserves_unified_exec_processes() { begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); assert_eq!(chat.unified_exec_processes.len(), 2); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_interrupted(&mut chat, "turn-1"); assert_eq!(chat.unified_exec_processes.len(), 2); @@ -1425,28 +1271,12 @@ async fn interrupt_preserves_unified_exec_processes() { async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); let begin = begin_unified_exec_startup(&mut chat, "call-1", "process-1", "just fix"); terminal_interaction(&mut chat, "call-1a", "process-1", ""); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_interrupted(&mut chat, "turn-1"); end_exec(&mut chat, begin, "", "", /*exit_code*/ 0); let cells = drain_insert_history(&mut rx); @@ -1467,16 +1297,7 @@ async fn turn_complete_keeps_unified_exec_processes() { begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); assert_eq!(chat.unified_exec_processes.len(), 2); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); assert_eq!(chat.unified_exec_processes.len(), 2); @@ -1518,10 +1339,7 @@ async fn apply_patch_events_emit_history_cells() { reason: None, grant_root: None, }; - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ev), - }); + handle_apply_patch_approval_request(&mut chat, "s1", ev); assert!( drain_insert_history(&mut rx).is_empty(), "expected approval request to surface via modal without emitting history cells" @@ -1535,16 +1353,7 @@ async fn apply_patch_events_emit_history_cells() { content: "hello\n".to_string(), }, ); - let begin = PatchApplyBeginEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - auto_approved: true, - changes: changes2, - }; - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyBegin(begin), - }); + handle_patch_apply_begin(&mut chat, "c1", "turn-c1", changes2); let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected apply block cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); @@ -1561,19 +1370,13 @@ async fn apply_patch_events_emit_history_cells() { content: "hello\n".to_string(), }, ); - let end = PatchApplyEndEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - stdout: "ok\n".into(), - stderr: String::new(), - success: true, - changes: end_changes, - status: CorePatchApplyStatus::Completed, - }; - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyEnd(end), - }); + handle_patch_apply_end( + &mut chat, + "c1", + "turn-c1", + end_changes, + AppServerPatchApplyStatus::Completed, + ); let cells = drain_insert_history(&mut rx); assert!( cells.is_empty(), @@ -1592,16 +1395,17 @@ async fn apply_patch_manual_approval_adjusts_header() { content: "hello\n".to_string(), }, ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + handle_apply_patch_approval_request( + &mut chat, + "s1", + ApplyPatchApprovalRequestEvent { call_id: "c1".into(), turn_id: "turn-c1".into(), changes: proposed_changes, reason: None, grant_root: None, - }), - }); + }, + ); drain_insert_history(&mut rx); let mut apply_changes = HashMap::new(); @@ -1611,15 +1415,7 @@ async fn apply_patch_manual_approval_adjusts_header() { content: "hello\n".to_string(), }, ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - auto_approved: false, - changes: apply_changes, - }), - }); + handle_patch_apply_begin(&mut chat, "c1", "turn-c1", apply_changes); let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected apply block cell to be sent"); @@ -1641,16 +1437,17 @@ async fn apply_patch_manual_flow_snapshot() { content: "hello\n".to_string(), }, ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + handle_apply_patch_approval_request( + &mut chat, + "s1", + ApplyPatchApprovalRequestEvent { call_id: "c1".into(), turn_id: "turn-c1".into(), changes: proposed_changes, reason: Some("Manual review required".into()), grant_root: None, - }), - }); + }, + ); let history_before_apply = drain_insert_history(&mut rx); assert!( history_before_apply.is_empty(), @@ -1664,15 +1461,7 @@ async fn apply_patch_manual_flow_snapshot() { content: "hello\n".to_string(), }, ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - auto_approved: false, - changes: apply_changes, - }), - }); + handle_patch_apply_begin(&mut chat, "c1", "turn-c1", apply_changes); let approved_lines = drain_insert_history(&mut rx) .pop() .expect("approved patch cell"); @@ -1701,10 +1490,7 @@ async fn apply_patch_approval_sends_op_with_call_id() { reason: None, grant_root: None, }; - chat.handle_codex_event(Event { - id: "sub-123".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ev), - }); + handle_apply_patch_approval_request(&mut chat, "sub-123", ev); // Approve via key press 'y' chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); @@ -1713,12 +1499,15 @@ async fn apply_patch_approval_sends_op_with_call_id() { let mut found = false; while let Ok(app_ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { - op: AppCommand::PatchApproval { id, decision }, + op: Op::PatchApproval { id, decision }, .. } = 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; } @@ -1736,23 +1525,24 @@ async fn apply_patch_full_flow_integration_like() { PathBuf::from("pkg.rs"), FileChange::Add { content: "".into() }, ); - chat.handle_codex_event(Event { - id: "sub-xyz".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + handle_apply_patch_approval_request( + &mut chat, + "sub-xyz", + ApplyPatchApprovalRequestEvent { call_id: "call-1".into(), turn_id: "turn-call-1".into(), changes, reason: None, grant_root: None, - }), - }); + }, + ); // 2) User approves via 'y' and App receives a thread-scoped op chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); let mut maybe_op: Option = None; while let Ok(app_ev) = rx.try_recv() { if let AppEvent::SubmitThreadOp { op, .. } = app_ev { - maybe_op = Some(op.into_core()); + maybe_op = Some(op); break; } } @@ -1766,7 +1556,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:?}"), } @@ -1777,32 +1570,19 @@ async fn apply_patch_full_flow_integration_like() { PathBuf::from("pkg.rs"), FileChange::Add { content: "".into() }, ); - chat.handle_codex_event(Event { - id: "sub-xyz".into(), - msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "call-1".into(), - turn_id: "turn-call-1".into(), - auto_approved: false, - changes: changes2, - }), - }); + handle_patch_apply_begin(&mut chat, "call-1", "turn-call-1", changes2); let mut end_changes = HashMap::new(); end_changes.insert( PathBuf::from("pkg.rs"), FileChange::Add { content: "".into() }, ); - chat.handle_codex_event(Event { - id: "sub-xyz".into(), - msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: "call-1".into(), - turn_id: "turn-call-1".into(), - stdout: String::from("ok"), - stderr: String::new(), - success: true, - changes: end_changes, - status: CorePatchApplyStatus::Completed, - }), - }); + handle_patch_apply_end( + &mut chat, + "call-1", + "turn-call-1", + end_changes, + AppServerPatchApplyStatus::Completed, + ); } #[tokio::test] @@ -1812,7 +1592,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(); @@ -1820,16 +1600,17 @@ async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> { PathBuf::from("a.rs"), FileChange::Add { content: "".into() }, ); - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + handle_apply_patch_approval_request( + &mut chat, + "sub-1", + ApplyPatchApprovalRequestEvent { call_id: "call-1".into(), turn_id: "turn-call-1".into(), changes, reason: None, grant_root: None, - }), - }); + }, + ); // Render and ensure the approval modal title is present let area = Rect::new(0, 0, 80, 12); @@ -1863,7 +1644,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(); @@ -1874,16 +1655,17 @@ async fn apply_patch_request_omits_diff_summary_from_modal() -> anyhow::Result<( content: "line one\nline two\n".into(), }, ); - chat.handle_codex_event(Event { - id: "sub-apply".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + handle_apply_patch_approval_request( + &mut chat, + "sub-apply", + ApplyPatchApprovalRequestEvent { call_id: "call-apply".into(), turn_id: "turn-apply".into(), changes, reason: None, grant_root: None, - }), - }); + }, + ); assert!( drain_insert_history(&mut rx).is_empty(), diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index e8957ff0e..c51b3f668 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -23,10 +23,7 @@ fn auto_review_denial_event() -> GuardianAssessmentEvent { async fn auto_review_denials_popup_lists_stored_auto_review_denials() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "guardian-assessment".into(), - msg: EventMsg::GuardianAssessment(auto_review_denial_event()), - }); + chat.on_guardian_assessment(auto_review_denial_event()); drain_insert_history(&mut rx); chat.open_auto_review_denials_popup(); @@ -40,10 +37,7 @@ async fn approving_recent_denial_emits_structured_core_op_once() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); - chat.handle_codex_event(Event { - id: "guardian-assessment".into(), - msg: EventMsg::GuardianAssessment(auto_review_denial_event()), - }); + chat.on_guardian_assessment(auto_review_denial_event()); drain_insert_history(&mut rx); chat.approve_recent_auto_review_denial(thread_id, "auto-review-recent-1".to_string()); @@ -52,7 +46,7 @@ async fn approving_recent_denial_emits_structured_core_op_once() { rx.try_recv(), Ok(AppEvent::SubmitThreadOp { thread_id: submitted_thread_id, - op: AppCommand::ApproveGuardianDeniedAction { event } + op: Op::ApproveGuardianDeniedAction { event } }) if submitted_thread_id == thread_id && event.id == "auto-review-recent-1" && event.status == GuardianAssessmentStatus::Denied @@ -75,39 +69,28 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { cwd: test_path_buf("/tmp").abs(), }; - chat.handle_codex_event(Event { - id: "guardian-in-progress".into(), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-1".into(), - target_item_id: Some("guardian-target-1".into()), - turn_id: "turn-1".into(), - status: GuardianAssessmentStatus::InProgress, - risk_level: None, - user_authorization: None, - rationale: None, - decision_source: None, - action: action.clone(), - }), + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-1".into(), + target_item_id: Some("guardian-target-1".into()), + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: action.clone(), }); - chat.handle_codex_event(Event { - id: "guardian-warning".into(), - msg: EventMsg::GuardianWarning(WarningEvent { - message: "Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint.".into(), - }), - }); - chat.handle_codex_event(Event { - id: "guardian-assessment".into(), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-1".into(), - target_item_id: Some("guardian-target-1".into()), - turn_id: "turn-1".into(), - status: GuardianAssessmentStatus::Denied, - risk_level: Some(GuardianRiskLevel::High), - user_authorization: Some(GuardianUserAuthorization::Low), - rationale: Some("Would exfiltrate local source code.".into()), - decision_source: Some(GuardianAssessmentDecisionSource::Agent), - action, - }), + chat.on_warning("Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint."); + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-1".into(), + target_item_id: Some("guardian-target-1".into()), + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::Denied, + risk_level: Some(GuardianRiskLevel::High), + user_authorization: Some(GuardianUserAuthorization::Low), + rationale: Some("Would exfiltrate local source code.".into()), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), + action, }); let width: u16 = 140; @@ -140,23 +123,20 @@ async fn guardian_approved_exec_renders_approved_request() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; - chat.handle_codex_event(Event { - id: "guardian-assessment".into(), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "thread:child-thread:guardian-1".into(), - target_item_id: Some("guardian-approved-target".into()), - turn_id: "turn-1".into(), - status: GuardianAssessmentStatus::Approved, - risk_level: Some(GuardianRiskLevel::Low), - user_authorization: Some(GuardianUserAuthorization::High), - rationale: Some("Narrowly scoped to the requested file.".into()), - decision_source: Some(GuardianAssessmentDecisionSource::Agent), - action: GuardianAssessmentAction::Command { - source: GuardianCommandSource::Shell, - command: "rm -f /tmp/guardian-approved.sqlite".to_string(), - cwd: test_path_buf("/tmp").abs(), - }, - }), + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "thread:child-thread:guardian-1".into(), + target_item_id: Some("guardian-approved-target".into()), + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::Approved, + risk_level: Some(GuardianRiskLevel::Low), + user_authorization: Some(GuardianUserAuthorization::High), + rationale: Some("Narrowly scoped to the requested file.".into()), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), + action: GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -f /tmp/guardian-approved.sqlite".to_string(), + cwd: test_path_buf("/tmp").abs(), + }, }); let width: u16 = 120; @@ -199,19 +179,16 @@ async fn guardian_approved_request_permissions_renders_request_summary() { }, }; - chat.handle_codex_event(Event { - id: "guardian-in-progress".into(), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-request-permissions".into(), - target_item_id: None, - turn_id: "turn-1".into(), - status: GuardianAssessmentStatus::InProgress, - risk_level: None, - user_authorization: None, - rationale: None, - decision_source: None, - action: action.clone(), - }), + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-request-permissions".into(), + target_item_id: None, + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: action.clone(), }); let status = chat @@ -224,19 +201,16 @@ async fn guardian_approved_request_permissions_renders_request_summary() { Some("permission request: Need write access for generated report assets.") ); - chat.handle_codex_event(Event { - id: "guardian-assessment".into(), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-request-permissions".into(), - target_item_id: None, - turn_id: "turn-1".into(), - status: GuardianAssessmentStatus::Approved, - risk_level: Some(GuardianRiskLevel::Low), - user_authorization: Some(GuardianUserAuthorization::High), - rationale: Some("Request is scoped to report output.".into()), - decision_source: Some(GuardianAssessmentDecisionSource::Agent), - action, - }), + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-request-permissions".into(), + target_item_id: None, + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::Approved, + risk_level: Some(GuardianRiskLevel::Low), + user_authorization: Some(GuardianUserAuthorization::High), + rationale: Some("Request is scoped to report output.".into()), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), + action, }); let width: u16 = 110; @@ -275,43 +249,30 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { cwd: test_path_buf("/tmp").abs(), }; - chat.handle_codex_event(Event { - id: "guardian-in-progress".into(), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-1".into(), - target_item_id: Some("guardian-target-1".into()), - turn_id: "turn-1".into(), - status: GuardianAssessmentStatus::InProgress, - risk_level: None, - user_authorization: None, - rationale: None, - decision_source: None, - action: action.clone(), - }), + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-1".into(), + target_item_id: Some("guardian-target-1".into()), + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: action.clone(), }); - chat.handle_codex_event(Event { - id: "guardian-warning".into(), - msg: EventMsg::GuardianWarning(WarningEvent { - message: "Automatic approval review timed out while evaluating the requested approval." - .into(), - }), - }); - chat.handle_codex_event(Event { - id: "guardian-assessment".into(), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-1".into(), - target_item_id: Some("guardian-target-1".into()), - turn_id: "turn-1".into(), - status: GuardianAssessmentStatus::TimedOut, - risk_level: None, - user_authorization: None, - rationale: Some( - "Automatic approval review timed out while evaluating the requested approval." - .into(), - ), - decision_source: Some(GuardianAssessmentDecisionSource::Agent), - action, - }), + chat.on_warning("Automatic approval review timed out while evaluating the requested approval."); + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-1".into(), + target_item_id: Some("guardian-target-1".into()), + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::TimedOut, + risk_level: None, + user_authorization: None, + rationale: Some( + "Automatic approval review timed out while evaluating the requested approval.".into(), + ), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), + action, }); let width: u16 = 140; @@ -541,23 +502,20 @@ async fn guardian_parallel_reviews_render_aggregate_status_snapshot() { ("guardian-1", "rm -rf '/tmp/guardian target 1'"), ("guardian-2", "rm -rf '/tmp/guardian target 2'"), ] { - chat.handle_codex_event(Event { - id: format!("event-{id}"), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: id.to_string(), - target_item_id: Some(format!("{id}-target")), - turn_id: "turn-1".to_string(), - status: GuardianAssessmentStatus::InProgress, - risk_level: None, - user_authorization: None, - rationale: None, - decision_source: None, - action: GuardianAssessmentAction::Command { - source: GuardianCommandSource::Shell, - command: command.to_string(), - cwd: test_path_buf("/tmp").abs(), - }, - }), + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: id.to_string(), + target_item_id: Some(format!("{id}-target")), + turn_id: "turn-1".to_string(), + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: command.to_string(), + cwd: test_path_buf("/tmp").abs(), + }, }); } @@ -573,59 +531,50 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.on_task_started(); - chat.handle_codex_event(Event { - id: "event-guardian-1".into(), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-1".to_string(), - target_item_id: Some("guardian-1-target".to_string()), - turn_id: "turn-1".to_string(), - status: GuardianAssessmentStatus::InProgress, - risk_level: None, - user_authorization: None, - rationale: None, - decision_source: None, - action: GuardianAssessmentAction::Command { - source: GuardianCommandSource::Shell, - command: "rm -rf '/tmp/guardian target 1'".to_string(), - cwd: test_path_buf("/tmp").abs(), - }, - }), + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-1".to_string(), + target_item_id: Some("guardian-1-target".to_string()), + turn_id: "turn-1".to_string(), + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf '/tmp/guardian target 1'".to_string(), + cwd: test_path_buf("/tmp").abs(), + }, }); - chat.handle_codex_event(Event { - id: "event-guardian-2".into(), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-2".to_string(), - target_item_id: Some("guardian-2-target".to_string()), - turn_id: "turn-1".to_string(), - status: GuardianAssessmentStatus::InProgress, - risk_level: None, - user_authorization: None, - rationale: None, - decision_source: None, - action: GuardianAssessmentAction::Command { - source: GuardianCommandSource::Shell, - command: "rm -rf '/tmp/guardian target 2'".to_string(), - cwd: test_path_buf("/tmp").abs(), - }, - }), + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-2".to_string(), + target_item_id: Some("guardian-2-target".to_string()), + turn_id: "turn-1".to_string(), + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf '/tmp/guardian target 2'".to_string(), + cwd: test_path_buf("/tmp").abs(), + }, }); - chat.handle_codex_event(Event { - id: "event-guardian-1-denied".into(), - msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-1".to_string(), - target_item_id: Some("guardian-1-target".to_string()), - turn_id: "turn-1".to_string(), - status: GuardianAssessmentStatus::Denied, - risk_level: Some(GuardianRiskLevel::High), - user_authorization: Some(GuardianUserAuthorization::Low), - rationale: Some("Would delete important data.".to_string()), - decision_source: Some(GuardianAssessmentDecisionSource::Agent), - action: GuardianAssessmentAction::Command { - source: GuardianCommandSource::Shell, - command: "rm -rf '/tmp/guardian target 1'".to_string(), - cwd: test_path_buf("/tmp").abs(), - }, - }), + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-1".to_string(), + target_item_id: Some("guardian-1-target".to_string()), + turn_id: "turn-1".to_string(), + status: GuardianAssessmentStatus::Denied, + risk_level: Some(GuardianRiskLevel::High), + user_authorization: Some(GuardianUserAuthorization::Low), + rationale: Some("Would delete important data.".to_string()), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), + action: GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf '/tmp/guardian target 1'".to_string(), + cwd: test_path_buf("/tmp").abs(), + }, }); assert_eq!(chat.current_status.header, "Reviewing approval request"); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 015340bbb..bad3d5854 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(), ) } @@ -311,7 +311,7 @@ pub(super) async fn make_chatwidget_manual( goal_status_active_turn_started_at: None, external_editor_state: ExternalEditorState::Closed, realtime_conversation: RealtimeConversationUiState::default(), - last_rendered_user_message_event: None, + last_rendered_user_message_display: None, last_non_retry_error: None, }; widget.set_model(&resolved_model); @@ -483,35 +483,453 @@ pub(super) fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUs } } +fn thread_id(chat: &ChatWidget) -> String { + chat.thread_id.map(|id| id.to_string()).unwrap_or_default() +} + +fn token_usage_breakdown(usage: TokenUsage) -> codex_app_server_protocol::TokenUsageBreakdown { + codex_app_server_protocol::TokenUsageBreakdown { + total_tokens: usage.total_tokens, + input_tokens: usage.input_tokens, + cached_input_tokens: usage.cached_input_tokens, + output_tokens: usage.output_tokens, + reasoning_output_tokens: usage.reasoning_output_tokens, + } +} + +pub(super) fn handle_token_count(chat: &mut ChatWidget, info: Option) { + match info { + Some(info) => { + chat.handle_server_notification( + ServerNotification::ThreadTokenUsageUpdated( + codex_app_server_protocol::ThreadTokenUsageUpdatedNotification { + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + token_usage: codex_app_server_protocol::ThreadTokenUsage { + total: token_usage_breakdown(info.total_token_usage), + last: token_usage_breakdown(info.last_token_usage), + model_context_window: info.model_context_window, + }, + }, + ), + /*replay_kind*/ None, + ); + } + None => chat.set_token_info(/*info*/ None), + } +} + +pub(super) fn handle_error( + chat: &mut ChatWidget, + message: impl Into, + codex_error_info: Option, +) { + chat.handle_server_notification( + ServerNotification::Error(ErrorNotification { + error: AppServerTurnError { + message: message.into(), + codex_error_info, + additional_details: None, + }, + will_retry: false, + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_stream_error( + chat: &mut ChatWidget, + message: impl Into, + additional_details: Option, +) { + handle_stream_error_with_replay(chat, message, additional_details, /*replay_kind*/ None); +} + +pub(super) fn handle_stream_error_with_replay( + chat: &mut ChatWidget, + message: impl Into, + additional_details: Option, + replay_kind: Option, +) { + chat.handle_server_notification( + ServerNotification::Error(ErrorNotification { + error: AppServerTurnError { + message: message.into(), + codex_error_info: None, + additional_details, + }, + will_retry: true, + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + }), + replay_kind, + ); +} + +pub(super) fn handle_warning(chat: &mut ChatWidget, message: impl Into) { + chat.handle_server_notification( + ServerNotification::Warning(WarningNotification { + thread_id: Some(thread_id(chat)), + message: message.into(), + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_model_verification( + chat: &mut ChatWidget, + verifications: Vec, +) { + chat.handle_server_notification( + ServerNotification::ModelVerification(ModelVerificationNotification { + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + verifications, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_agent_message_delta(chat: &mut ChatWidget, delta: impl Into) { + chat.handle_server_notification( + ServerNotification::AgentMessageDelta( + codex_app_server_protocol::AgentMessageDeltaNotification { + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + item_id: "msg-1".to_string(), + delta: delta.into(), + }, + ), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_agent_reasoning_delta(chat: &mut ChatWidget, delta: impl Into) { + chat.handle_server_notification( + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + item_id: "reasoning-1".to_string(), + delta: delta.into(), + summary_index: 0, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_agent_reasoning_final(chat: &mut ChatWidget) { + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + item: AppServerThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: Vec::new(), + content: Vec::new(), + }, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_entered_review_mode(chat: &mut ChatWidget, review: impl Into) { + chat.handle_server_notification( + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + item: AppServerThreadItem::EnteredReviewMode { + id: "review-start".to_string(), + review: review.into(), + }, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn replay_entered_review_mode(chat: &mut ChatWidget, review: impl Into) { + chat.replay_thread_item( + AppServerThreadItem::EnteredReviewMode { + id: "review-start".to_string(), + review: review.into(), + }, + "turn-1".to_string(), + ReplayKind::ThreadSnapshot, + ); +} + +pub(super) fn handle_exited_review_mode(chat: &mut ChatWidget) { + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + item: AppServerThreadItem::ExitedReviewMode { + id: "review-end".to_string(), + review: String::new(), + }, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_exec_approval_request( + chat: &mut ChatWidget, + id: impl Into, + event: ExecApprovalRequestEvent, +) { + chat.on_exec_approval_request(id.into(), event); +} + +pub(super) fn handle_apply_patch_approval_request( + chat: &mut ChatWidget, + id: impl Into, + event: ApplyPatchApprovalRequestEvent, +) { + chat.on_apply_patch_approval_request(id.into(), event); +} + +fn file_update_changes_from_tui(changes: HashMap) -> Vec { + changes + .into_iter() + .map(|(path, change)| { + let (kind, diff) = match change { + FileChange::Add { content } => (PatchChangeKind::Add, content), + FileChange::Delete { content } => (PatchChangeKind::Delete, content), + FileChange::Update { + unified_diff, + move_path, + } => (PatchChangeKind::Update { move_path }, unified_diff), + }; + FileUpdateChange { + path: path.display().to_string(), + kind, + diff, + } + }) + .collect() +} + +pub(super) fn handle_patch_apply_begin( + chat: &mut ChatWidget, + call_id: impl Into, + turn_id: impl Into, + changes: HashMap, +) { + chat.handle_server_notification( + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: thread_id(chat), + turn_id: turn_id.into(), + item: AppServerThreadItem::FileChange { + id: call_id.into(), + changes: file_update_changes_from_tui(changes), + status: AppServerPatchApplyStatus::InProgress, + }, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_patch_apply_end( + chat: &mut ChatWidget, + call_id: impl Into, + turn_id: impl Into, + changes: HashMap, + status: AppServerPatchApplyStatus, +) { + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id(chat), + turn_id: turn_id.into(), + item: AppServerThreadItem::FileChange { + id: call_id.into(), + changes: file_update_changes_from_tui(changes), + status, + }, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_view_image_tool_call( + chat: &mut ChatWidget, + call_id: impl Into, + path: AbsolutePathBuf, +) { + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id(chat), + turn_id: "turn-1".to_string(), + item: AppServerThreadItem::ImageView { + id: call_id.into(), + path, + }, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_image_generation_end( + chat: &mut ChatWidget, + call_id: impl Into, + revised_prompt: Option, + saved_path: Option, +) { + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id(chat), + turn_id: "turn-1".to_string(), + item: AppServerThreadItem::ImageGeneration { + id: call_id.into(), + status: "completed".to_string(), + revised_prompt, + result: String::new(), + saved_path, + }, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn replay_user_message_inputs( + chat: &mut ChatWidget, + item_id: &str, + content: Vec, + replay_kind: ReplayKind, +) { + chat.replay_thread_item( + AppServerThreadItem::UserMessage { + id: item_id.to_string(), + content, + }, + "turn-1".to_string(), + replay_kind, + ); +} + +pub(super) fn replay_user_message_text( + chat: &mut ChatWidget, + item_id: &str, + text: impl Into, + replay_kind: ReplayKind, +) { + replay_user_message_inputs( + chat, + item_id, + vec![AppServerUserInput::Text { + text: text.into(), + text_elements: Vec::new(), + }], + replay_kind, + ); +} + +pub(super) fn replay_agent_message( + chat: &mut ChatWidget, + item_id: &str, + text: impl Into, + replay_kind: ReplayKind, +) { + chat.replay_thread_item( + AppServerThreadItem::AgentMessage { + id: item_id.to_string(), + text: text.into(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: None, + }, + "turn-1".to_string(), + replay_kind, + ); +} + +pub(super) fn replay_turn_started(chat: &mut ChatWidget, replay_kind: ReplayKind) { + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id(chat), + turn: app_server_turn( + "turn-1", + AppServerTurnStatus::InProgress, + /*duration_ms*/ None, + /*error*/ None, + ), + }), + Some(replay_kind), + ); +} + +pub(super) fn replay_agent_message_delta( + chat: &mut ChatWidget, + delta: impl Into, + replay_kind: ReplayKind, +) { + chat.handle_server_notification( + ServerNotification::AgentMessageDelta( + codex_app_server_protocol::AgentMessageDeltaNotification { + thread_id: thread_id(chat), + turn_id: "turn-1".to_string(), + item_id: "msg-1".to_string(), + delta: delta.into(), + }, + ), + Some(replay_kind), + ); +} + // --- Small helpers to tersely drive exec begin/end and snapshot active cell --- pub(super) fn begin_exec_with_source( chat: &mut ChatWidget, call_id: &str, raw_cmd: &str, source: ExecCommandSource, -) -> ExecCommandBeginEvent { +) -> AppServerThreadItem { // Build the full command vec and parse it using core's parser, // then convert to protocol variants for the event payload. let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; - let parsed_cmd: Vec = - codex_shell_command::parse_command::parse_command(&command); - let cwd = AbsolutePathBuf::current_dir().expect("current dir"); - let interaction_input = None; - let event = ExecCommandBeginEvent { - call_id: call_id.to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source, - interaction_input, - }; - chat.handle_codex_event(Event { + let command_actions = codex_shell_command::parse_command::parse_command(&command) + .into_iter() + .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) + .collect(); + let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), - msg: EventMsg::ExecCommandBegin(event.clone()), - }); - event + command: codex_shell_command::parse_command::shlex_join(&command), + cwd: chat.config.cwd.clone(), + process_id: None, + source, + status: AppServerCommandExecutionStatus::InProgress, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + handle_exec_begin(chat, item.clone()); + item } pub(super) fn begin_unified_exec_startup( @@ -519,24 +937,36 @@ pub(super) fn begin_unified_exec_startup( call_id: &str, process_id: &str, raw_cmd: &str, -) -> ExecCommandBeginEvent { +) -> AppServerThreadItem { let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; - let cwd = AbsolutePathBuf::current_dir().expect("current dir"); - let event = ExecCommandBeginEvent { - call_id: call_id.to_string(), - process_id: Some(process_id.to_string()), - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd: Vec::new(), - source: ExecCommandSource::UnifiedExecStartup, - interaction_input: None, - }; - chat.handle_codex_event(Event { + let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), - msg: EventMsg::ExecCommandBegin(event.clone()), - }); - event + command: codex_shell_command::parse_command::shlex_join(&command), + cwd: chat.config.cwd.clone(), + process_id: Some(process_id.to_string()), + source: ExecCommandSource::UnifiedExecStartup, + status: AppServerCommandExecutionStatus::InProgress, + command_actions: Vec::new(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + handle_exec_begin(chat, item.clone()); + item +} + +pub(super) fn handle_exec_begin(chat: &mut ChatWidget, item: AppServerThreadItem) { + chat.handle_server_notification( + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + item, + }), + /*replay_kind*/ None, + ); } pub(super) fn terminal_interaction( @@ -545,14 +975,21 @@ pub(super) fn terminal_interaction( process_id: &str, stdin: &str, ) { - chat.handle_codex_event(Event { - id: call_id.to_string(), - msg: EventMsg::TerminalInteraction(TerminalInteractionEvent { - call_id: call_id.to_string(), - process_id: process_id.to_string(), - stdin: stdin.to_string(), - }), - }); + chat.handle_server_notification( + ServerNotification::TerminalInteraction( + codex_app_server_protocol::TerminalInteractionNotification { + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + item_id: call_id.to_string(), + process_id: process_id.to_string(), + stdin: stdin.to_string(), + }, + ), + /*replay_kind*/ None, + ); } pub(super) fn complete_assistant_message( @@ -561,21 +998,19 @@ pub(super) fn complete_assistant_message( text: &str, phase: Option, ) { - chat.handle_codex_event(Event { - id: format!("raw-{item_id}"), - msg: EventMsg::ItemCompleted(ItemCompletedEvent { - thread_id: ThreadId::new(), + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn_id: "turn-1".to_string(), - item: TurnItem::AgentMessage(AgentMessageItem { + item: AppServerThreadItem::AgentMessage { id: item_id.to_string(), - content: vec![AgentMessageContent::Text { - text: text.to_string(), - }], + text: text.to_string(), phase, memory_citation: None, - }), + }, }), - }); + /*replay_kind*/ None, + ); } pub(super) fn pending_steer(text: &str) -> PendingSteer { @@ -605,30 +1040,101 @@ pub(super) fn complete_user_message_for_inputs( item_id: &str, content: Vec, ) { - chat.handle_codex_event(Event { - id: format!("raw-{item_id}"), - msg: EventMsg::ItemCompleted(ItemCompletedEvent { - thread_id: ThreadId::new(), + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn_id: "turn-1".to_string(), - item: TurnItem::UserMessage(UserMessageItem { + item: AppServerThreadItem::UserMessage { id: item_id.to_string(), content, - }), + }, }), - }); + /*replay_kind*/ None, + ); +} + +pub(super) fn app_server_turn( + turn_id: &str, + status: AppServerTurnStatus, + duration_ms: Option, + error: Option, +) -> AppServerTurn { + AppServerTurn { + id: turn_id.to_string(), + items: Vec::new(), + status, + error, + started_at: None, + completed_at: None, + duration_ms, + } +} + +pub(super) fn handle_turn_started(chat: &mut ChatWidget, turn_id: &str) { + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), + turn: app_server_turn( + turn_id, + AppServerTurnStatus::InProgress, + /*duration_ms*/ None, + /*error*/ None, + ), + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_turn_completed( + chat: &mut ChatWidget, + turn_id: &str, + duration_ms: Option, +) { + chat.handle_server_notification( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), + turn: app_server_turn( + turn_id, + AppServerTurnStatus::Completed, + duration_ms, + /*error*/ None, + ), + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_turn_interrupted(chat: &mut ChatWidget, turn_id: &str) { + chat.handle_server_notification( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), + turn: app_server_turn( + turn_id, + AppServerTurnStatus::Interrupted, + /*duration_ms*/ None, + /*error*/ None, + ), + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_budget_limited_turn(chat: &mut ChatWidget, turn_id: &str) { + chat.budget_limited_turn_ids.insert(turn_id.to_string()); + handle_turn_interrupted(chat, turn_id); } pub(super) fn begin_exec( chat: &mut ChatWidget, call_id: &str, raw_cmd: &str, -) -> ExecCommandBeginEvent { +) -> AppServerThreadItem { begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent) } pub(super) fn end_exec( chat: &mut ChatWidget, - begin_event: ExecCommandBeginEvent, + begin_item: AppServerThreadItem, stdout: &str, stderr: &str, exit_code: i32, @@ -638,40 +1144,51 @@ pub(super) fn end_exec( } else { format!("{stdout}{stderr}") }; - let ExecCommandBeginEvent { - call_id, - turn_id, + let AppServerThreadItem::CommandExecution { + id, command, cwd, - parsed_cmd, - source, - interaction_input, process_id, - } = begin_event; - chat.handle_codex_event(Event { - id: call_id.clone(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id, - process_id, - turn_id, + source, + command_actions, + .. + } = begin_item + else { + panic!("expected command execution item"); + }; + handle_exec_end( + chat, + AppServerThreadItem::CommandExecution { + id, command, cwd, - parsed_cmd, + process_id, source, - interaction_input, - stdout: stdout.to_string(), - stderr: stderr.to_string(), - aggregated_output: aggregated.clone(), - exit_code, - duration: std::time::Duration::from_millis(5), - formatted_output: aggregated, status: if exit_code == 0 { - CoreExecCommandStatus::Completed + AppServerCommandExecutionStatus::Completed } else { - CoreExecCommandStatus::Failed + AppServerCommandExecutionStatus::Failed }, + command_actions, + aggregated_output: (!aggregated.is_empty()).then_some(aggregated), + exit_code: Some(exit_code), + duration_ms: Some(5), + }, + ); +} + +pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem) { + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id(chat), + turn_id: chat + .last_turn_id + .clone() + .unwrap_or_else(|| "turn-1".to_string()), + item, }), - }); + /*replay_kind*/ None, + ); } pub(super) fn active_blob(chat: &ChatWidget) -> String { @@ -1001,36 +1518,85 @@ pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { } } +pub(super) fn handle_hook_started(chat: &mut ChatWidget, run: AppServerHookRunSummary) { + chat.handle_server_notification( + ServerNotification::HookStarted(AppServerHookStartedNotification { + thread_id: thread_id(chat), + turn_id: None, + run, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn handle_hook_completed(chat: &mut ChatWidget, run: AppServerHookRunSummary) { + chat.handle_server_notification( + ServerNotification::HookCompleted(AppServerHookCompletedNotification { + thread_id: thread_id(chat), + turn_id: None, + run, + }), + /*replay_kind*/ None, + ); +} + +pub(super) fn hook_run( + run_id: &str, + event_name: codex_app_server_protocol::HookEventName, + status: codex_app_server_protocol::HookRunStatus, + status_message: &str, + entries: Vec, +) -> codex_app_server_protocol::HookRunSummary { + codex_app_server_protocol::HookRunSummary { + id: run_id.to_string(), + event_name, + 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_app_server_protocol::HookSource::User, + display_order: 0, + status, + status_message: Some(status_message.to_string()), + started_at: 1, + completed_at: matches!( + status, + codex_app_server_protocol::HookRunStatus::Completed + | codex_app_server_protocol::HookRunStatus::Failed + | codex_app_server_protocol::HookRunStatus::Blocked + | codex_app_server_protocol::HookRunStatus::Stopped + ) + .then_some(11), + duration_ms: matches!( + status, + codex_app_server_protocol::HookRunStatus::Completed + | codex_app_server_protocol::HookRunStatus::Failed + | codex_app_server_protocol::HookRunStatus::Blocked + | codex_app_server_protocol::HookRunStatus::Stopped + ) + .then_some(10), + entries, + } +} + 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, ) { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "hook-1".into(), - msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { - turn_id: None, - run: codex_protocol::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, - source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), - source: codex_protocol::protocol::HookSource::User, - display_order: 0, - status: codex_protocol::protocol::HookRunStatus::Running, - status_message: Some(status_message.to_string()), - started_at: 1, - completed_at: None, - duration_ms: None, - entries: vec![], - }, - }), - }); + handle_hook_started( + &mut chat, + hook_run( + run_id, + event_name, + codex_app_server_protocol::HookRunStatus::Running, + status_message, + Vec::new(), + ), + ); assert!( drain_insert_history(&mut rx).is_empty(), "hook start should update the live hook cell instead of writing history" @@ -1044,37 +1610,25 @@ pub(super) async fn assert_hook_events_snapshot( "hook start should render in the live hook cell" ); - chat.handle_codex_event(Event { - id: "hook-1".into(), - msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { - turn_id: None, - run: codex_protocol::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, - source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), - source: codex_protocol::protocol::HookSource::User, - display_order: 0, - status: codex_protocol::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, - text: "Heads up from the hook".to_string(), - }, - codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Context, - text: "Remember the startup checklist.".to_string(), - }, - ], - }, - }), - }); + handle_hook_completed( + &mut chat, + hook_run( + run_id, + event_name, + codex_app_server_protocol::HookRunStatus::Completed, + status_message, + vec![ + codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Warning, + text: "Heads up from the hook".to_string(), + }, + codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Context, + text: "Remember the startup checklist.".to_string(), + }, + ], + ), + ); let cells = drain_insert_history(&mut rx); let combined = cells @@ -1084,13 +1638,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 5fdb615b9..ebc8cee9f 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,42 +16,40 @@ async fn resumed_initial_messages_render_history() { let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: codex_protocol::models::PermissionProfile::read_only(), + permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: Some(vec![ - EventMsg::UserMessage(UserMessageEvent { - message: "hello from user".to_string(), - images: None, - text_elements: Vec::new(), - local_images: Vec::new(), - }), - EventMsg::AgentMessage(AgentMessageEvent { - message: "assistant reply".to_string(), - phase: None, - memory_citation: None, - }), - ]), network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); + replay_user_message_text( + &mut chat, + "user-1", + "hello from user", + ReplayKind::ResumeInitialMessages, + ); + replay_agent_message( + &mut chat, + "assistant-1", + "assistant reply", + ReplayKind::ResumeInitialMessages, + ); let cells = drain_insert_history(&mut rx); let mut merged_lines = Vec::new(); @@ -73,47 +73,6 @@ async fn resumed_initial_messages_render_history() { ); } -#[tokio::test] -async fn thread_snapshot_replay_does_not_duplicate_agent_message_history() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.handle_codex_event_replay(Event { - id: "turn-1".into(), - msg: EventMsg::ItemCompleted(ItemCompletedEvent { - thread_id: ThreadId::new(), - turn_id: "turn-1".to_string(), - item: TurnItem::AgentMessage(AgentMessageItem { - id: "msg-1".to_string(), - content: vec![AgentMessageContent::Text { - text: "assistant reply".to_string(), - }], - phase: None, - memory_citation: None, - }), - }), - }); - chat.handle_codex_event_replay(Event { - id: "turn-1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "assistant reply".to_string(), - phase: None, - memory_citation: None, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!( - cells.len(), - 1, - "expected replayed assistant message to render once" - ); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("assistant reply"), - "expected replayed assistant message, got {rendered:?}" - ); -} - #[tokio::test] async fn replayed_user_message_preserves_text_elements_and_local_images() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; @@ -128,35 +87,42 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: codex_protocol::models::PermissionProfile::read_only(), + permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { - message: message.clone(), - images: None, - text_elements: text_elements.clone(), - local_images: local_images.clone(), - })]), network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); + replay_user_message_inputs( + &mut chat, + "user-1", + vec![ + AppServerUserInput::Text { + text: message.clone(), + text_elements: text_elements.clone().into_iter().map(Into::into).collect(), + }, + AppServerUserInput::LocalImage { + path: local_images[0].clone(), + }, + ], + ReplayKind::ResumeInitialMessages, + ); let mut user_cell = None; while let Ok(ev) = rx.try_recv() { @@ -190,35 +156,42 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: codex_protocol::models::PermissionProfile::read_only(), + permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { - message: message.clone(), - images: Some(remote_image_urls.clone()), - text_elements: Vec::new(), - local_images: Vec::new(), - })]), network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); + replay_user_message_inputs( + &mut chat, + "user-1", + vec![ + AppServerUserInput::Text { + text: message.clone(), + text_elements: Vec::new(), + }, + AppServerUserInput::Image { + url: remote_image_urls[0].clone(), + }, + ], + ReplayKind::ResumeInitialMessages, + ); let mut user_cell = None; while let Ok(ev) = rx.try_recv() { @@ -248,7 +221,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 @@ -257,64 +230,64 @@ 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 { - session_id: ThreadId::new(), + let expected_sandbox = SandboxPolicy::from(expected_core_sandbox); + let configured = crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: expected_permission_profile.clone(), + permission_profile: expected_permission_profile, active_permission_profile: None, cwd: expected_cwd.clone(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: None, }; - chat.handle_codex_event(Event { - id: "session-configured".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); 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); @@ -332,15 +305,18 @@ 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 { - session_id: ThreadId::new(), + let expected_permission_profile: PermissionProfile = + expected_app_server_permission_profile.clone().into(); + let expected_sandbox = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }; + let configured = crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -350,33 +326,21 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { permission_profile: expected_permission_profile, active_permission_profile: None, cwd: test_path_buf("/home/user/external").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: None, }; - chat.handle_codex_event(Event { - id: "session-configured".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(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 ); } @@ -388,35 +352,36 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: codex_protocol::models::PermissionProfile::read_only(), + permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { - message: String::new(), - images: Some(remote_image_urls.clone()), - text_elements: Vec::new(), - local_images: Vec::new(), - })]), network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); + replay_user_message_inputs( + &mut chat, + "user-1", + vec![AppServerUserInput::Image { + url: remote_image_urls[0].clone(), + }], + ReplayKind::ResumeInitialMessages, + ); let mut user_cell = None; while let Ok(ev) = rx.try_recv() { @@ -438,39 +403,40 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { async fn replayed_user_message_with_only_local_images_does_not_render_history_cell() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - let local_images = vec![PathBuf::from("/tmp/replay-local-only.png")]; + let local_images = [PathBuf::from("/tmp/replay-local-only.png")]; let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_protocol::protocol::SessionConfiguredEvent { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: codex_protocol::models::PermissionProfile::read_only(), + permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { - message: String::new(), - images: None, - text_elements: Vec::new(), - local_images, - })]), network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); + replay_user_message_inputs( + &mut chat, + "user-1", + vec![AppServerUserInput::LocalImage { + path: local_images[0].clone(), + }], + ReplayKind::ResumeInitialMessages, + ); let mut found_user_history_cell = false; while let Ok(ev) = rx.try_recv() { @@ -630,75 +596,21 @@ async fn app_server_forked_thread_history_line_without_app_server_name_ignores_l async fn thread_snapshot_replay_preserves_agent_message_during_review_mode() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event_replay(Event { - id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }), - }); + replay_entered_review_mode(&mut chat, "current changes"); let _ = drain_insert_history(&mut rx); - chat.handle_codex_event_replay(Event { - id: "review-message".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Review progress update".to_string(), - phase: None, - memory_citation: None, - }), - }); + replay_agent_message( + &mut chat, + "review-message", + "Review progress update", + ReplayKind::ThreadSnapshot, + ); let inserted = drain_insert_history(&mut rx); assert_eq!(inserted.len(), 1); assert!(lines_to_single_string(&inserted[0]).contains("Review progress update")); } -#[tokio::test] -async fn replayed_thread_rollback_emits_ordered_app_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; - - chat.replay_initial_messages(vec![EventMsg::ThreadRolledBack(ThreadRolledBackEvent { - num_turns: 2, - })]); - - let mut saw = false; - while let Ok(event) = rx.try_recv() { - if let AppEvent::ApplyThreadRollback { num_turns } = event { - saw = true; - assert_eq!(num_turns, 2); - break; - } - } - - assert!(saw, "expected replay rollback app event"); -} - -#[tokio::test] -async fn live_legacy_agent_message_after_item_completed_does_not_duplicate_assistant_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - complete_assistant_message( - &mut chat, - "msg-live", - "hello", - Some(MessagePhase::FinalAnswer), - ); - let inserted = drain_insert_history(&mut rx); - assert_eq!(inserted.len(), 1); - assert!(lines_to_single_string(&inserted[0]).contains("hello")); - - chat.handle_codex_event(Event { - id: "legacy-live".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "hello".into(), - phase: Some(MessagePhase::FinalAnswer), - memory_citation: None, - }), - }); - - assert!(drain_insert_history(&mut rx).is_empty()); -} - #[tokio::test] async fn replayed_retryable_app_server_error_keeps_turn_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -762,27 +674,25 @@ async fn replayed_thread_closed_notification_does_not_exit_tui() { async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.show_raw_agent_reasoning = false; - chat.handle_codex_event(Event { - id: "configured".into(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - thread_name: None, - model: "test-model".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - permission_profile: codex_protocol::models::PermissionProfile::read_only(), - active_permission_profile: None, - cwd: test_project_path().abs(), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: None, - }), + chat.handle_thread_session(crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_project_path().abs(), + instruction_source_paths: Vec::new(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + network_proxy: None, + rollout_path: None, }); let _ = drain_insert_history(&mut rx); @@ -810,27 +720,25 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.show_raw_agent_reasoning = true; - chat.handle_codex_event(Event { - id: "configured".into(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - thread_name: None, - model: "test-model".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - permission_profile: codex_protocol::models::PermissionProfile::read_only(), - active_permission_profile: None, - cwd: test_project_path().abs(), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: None, - }), + chat.handle_thread_session(crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_project_path().abs(), + instruction_source_paths: Vec::new(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + network_proxy: None, + rollout_path: None, }); let _ = drain_insert_history(&mut rx); @@ -908,34 +816,11 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { assert_eq!(rendered.matches("Summary only").count(), 1); } -#[tokio::test] -async fn replayed_turn_started_does_not_mark_task_running() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - })]); - - assert!(!chat.bottom_pane.is_task_running()); - assert!(chat.bottom_pane.status_widget().is_none()); -} - #[tokio::test] async fn thread_snapshot_replayed_turn_started_marks_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event_replay(Event { - id: "turn-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + replay_turn_started(&mut chat, ReplayKind::ThreadSnapshot); drain_insert_history(&mut rx); assert!(chat.bottom_pane.is_task_running()); @@ -977,11 +862,12 @@ async fn replayed_stream_error_does_not_set_retry_status_or_status_indicator() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_status_header("Idle".to_string()); - chat.replay_initial_messages(vec![EventMsg::StreamError(StreamErrorEvent { - message: "Reconnecting... 2/5".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: Some("Idle timeout waiting for SSE".to_string()), - })]); + handle_stream_error_with_replay( + &mut chat, + "Reconnecting... 2/5", + Some("Idle timeout waiting for SSE".to_string()), + Some(ReplayKind::ResumeInitialMessages), + ); let cells = drain_insert_history(&mut rx); assert!( @@ -997,33 +883,18 @@ async fn replayed_stream_error_does_not_set_retry_status_or_status_indicator() { async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event_replay(Event { - id: "task".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + replay_turn_started(&mut chat, ReplayKind::ThreadSnapshot); drain_insert_history(&mut rx); - chat.handle_codex_event_replay(Event { - id: "retry".into(), - msg: EventMsg::StreamError(StreamErrorEvent { - message: "Reconnecting... 1/5".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: None, - }), - }); + handle_stream_error_with_replay( + &mut chat, + "Reconnecting... 1/5", + /*additional_details*/ None, + Some(ReplayKind::ThreadSnapshot), + ); drain_insert_history(&mut rx); - chat.handle_codex_event_replay(Event { - id: "delta".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "hello".to_string(), - }), - }); + replay_agent_message_delta(&mut chat, "hello", ReplayKind::ThreadSnapshot); let status = chat .bottom_pane @@ -1034,93 +905,18 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade assert!(chat.retry_status_header.is_none()); } -#[tokio::test] -async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.set_status_header("Idle".to_string()); - - chat.replay_initial_messages(vec![ - EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - EventMsg::StreamError(StreamErrorEvent { - message: "Reconnecting... 1/5".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: None, - }), - EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "hello".to_string(), - }), - ]); - - let cells = drain_insert_history(&mut rx); - assert!( - cells.is_empty(), - "expected no history cells for replayed interrupted reconnect sequence" - ); - assert!(!chat.bottom_pane.is_task_running()); - assert!(chat.bottom_pane.status_widget().is_none()); - assert_eq!(chat.current_status.header, "Idle"); - assert!(chat.retry_status_header.is_none()); -} - -#[tokio::test] -async fn replayed_interrupted_reconnect_footer_row_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.replay_initial_messages(vec![ - EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - EventMsg::StreamError(StreamErrorEvent { - message: "Reconnecting... 2/5".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: Some("Idle timeout waiting for SSE".to_string()), - }), - ]); - - let header = render_bottom_first_row(&chat, /*width*/ 80); - assert!( - !header.contains("Reconnecting") && !header.contains("Working"), - "expected replayed interrupted reconnect to avoid active status row, got {header:?}" - ); - assert_chatwidget_snapshot!("replayed_interrupted_reconnect_footer_row", header); -} - #[tokio::test] async fn stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "task".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "retry".into(), - msg: EventMsg::StreamError(StreamErrorEvent { - message: "Reconnecting... 1/5".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: None, - }), - }); + handle_stream_error( + &mut chat, + "Reconnecting... 1/5", + /*additional_details*/ None, + ); drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "delta".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "hello".to_string(), - }), - }); + handle_agent_message_delta(&mut chat, "hello"); let status = chat .bottom_pane diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 6baed81a4..977bea288 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -1,21 +1,34 @@ use super::*; -use codex_protocol::protocol::McpStartupCompleteEvent; -use codex_protocol::protocol::McpStartupStatus; -use codex_protocol::protocol::McpStartupUpdateEvent; use pretty_assertions::assert_eq; +fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartupState) { + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: name.to_string(), + status, + error: None, + }), + /*replay_kind*/ None, + ); +} + +fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) { + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: name.to_string(), + status: McpServerStartupState::Failed, + error: Some(error.to_string()), + }), + /*replay_kind*/ None, + ); +} + #[tokio::test] async fn mcp_startup_header_booting_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; - chat.handle_codex_event(Event { - id: "mcp-1".into(), - msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent { - server: "alpha".into(), - status: McpStartupStatus::Starting, - }), - }); + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); let height = chat.desired_height(/*width*/ 80); let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) @@ -33,26 +46,14 @@ async fn mcp_startup_header_booting_snapshot() { async fn mcp_startup_complete_does_not_clear_running_task() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); assert!(chat.bottom_pane.is_task_running()); assert!(chat.bottom_pane.status_indicator_visible()); - chat.handle_codex_event(Event { - id: "mcp-1".into(), - msg: EventMsg::McpStartupComplete(McpStartupCompleteEvent { - ready: vec!["schaltwerk".into()], - ..Default::default() - }), - }); + chat.set_mcp_startup_expected_servers(["schaltwerk".to_string()]); + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Starting); + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Ready); assert!(chat.bottom_pane.is_task_running()); assert!(chat.bottom_pane.status_indicator_visible()); @@ -64,25 +65,15 @@ async fn app_server_mcp_startup_failure_renders_warning_history() { chat.show_welcome_banner = false; chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); assert!(drain_insert_history(&mut rx).is_empty()); assert!(chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), - }), - /*replay_kind*/ None, + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", ); let failure_cells = drain_insert_history(&mut rx); @@ -94,26 +85,12 @@ async fn app_server_mcp_startup_failure_renders_warning_history() { assert!(!failure_text.contains("MCP startup incomplete")); assert!(chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); assert!(drain_insert_history(&mut rx).is_empty()); assert!(chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Ready, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); let summary_cells = drain_insert_history(&mut rx); let summary_text = summary_cells @@ -154,30 +131,13 @@ async fn app_server_mcp_startup_lag_settles_startup_and_ignores_late_updates() { chat.show_welcome_banner = false; chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), - }), - /*replay_kind*/ None, - ); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); let _ = drain_insert_history(&mut rx); assert!(chat.bottom_pane.is_task_running()); @@ -193,26 +153,12 @@ async fn app_server_mcp_startup_lag_settles_startup_and_ignores_late_updates() { assert!(summary_text.contains("MCP startup incomplete (failed: alpha)")); assert!(!chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); assert!(drain_insert_history(&mut rx).is_empty()); assert!(!chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Ready, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); assert!(drain_insert_history(&mut rx).is_empty()); assert!(!chat.bottom_pane.is_task_running()); @@ -226,13 +172,10 @@ async fn app_server_mcp_startup_after_lag_can_settle_without_starting_updates() chat.finish_mcp_startup_after_lag(); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), - }), - /*replay_kind*/ None, + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", ); let failure_text = drain_insert_history(&mut rx) @@ -242,14 +185,7 @@ async fn app_server_mcp_startup_after_lag_can_settle_without_starting_updates() assert!(failure_text.contains("MCP client for `alpha` failed to start: handshake failed")); assert!(chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Ready, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); let summary_text = drain_insert_history(&mut rx) .iter() @@ -265,43 +201,23 @@ async fn app_server_mcp_startup_after_lag_preserves_partial_terminal_only_round( chat.show_welcome_banner = false; chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), - }), - /*replay_kind*/ None, - ); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); let _ = drain_insert_history(&mut rx); chat.finish_mcp_startup_after_lag(); let _ = drain_insert_history(&mut rx); assert!(!chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), - }), - /*replay_kind*/ None, + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", ); assert!(drain_insert_history(&mut rx).is_empty()); @@ -309,14 +225,7 @@ async fn app_server_mcp_startup_after_lag_preserves_partial_terminal_only_round( chat.finish_mcp_startup_after_lag(); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Ready, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); let summary_text = drain_insert_history(&mut rx) .iter() @@ -333,78 +242,35 @@ async fn app_server_mcp_startup_next_round_discards_stale_terminal_updates() { chat.show_welcome_banner = false; chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), - }), - /*replay_kind*/ None, - ); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); let _ = drain_insert_history(&mut rx); chat.finish_mcp_startup_after_lag(); let _ = drain_insert_history(&mut rx); assert!(!chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some( - "MCP client for `alpha` failed to start: stale handshake failed".to_string(), - ), - }), - /*replay_kind*/ None, + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: stale handshake failed", ); assert!(drain_insert_history(&mut rx).is_empty()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); assert!(drain_insert_history(&mut rx).is_empty()); assert!(!chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Ready, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Ready); assert!(drain_insert_history(&mut rx).is_empty()); assert!(chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Ready, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); let summary_text = drain_insert_history(&mut rx) .iter() @@ -422,23 +288,13 @@ async fn app_server_mcp_startup_next_round_keeps_terminal_statuses_after_startin chat.finish_mcp_startup_after_lag(); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); assert!(drain_insert_history(&mut rx).is_empty()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), - }), - /*replay_kind*/ None, + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", ); let failure_text = drain_insert_history(&mut rx) @@ -447,25 +303,11 @@ async fn app_server_mcp_startup_next_round_keeps_terminal_statuses_after_startin .collect::(); assert!(failure_text.contains("MCP client for `alpha` failed to start: handshake failed")); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); assert!(drain_insert_history(&mut rx).is_empty()); assert!(chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Ready, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); let summary_text = drain_insert_history(&mut rx) .iter() @@ -482,24 +324,14 @@ async fn app_server_mcp_startup_next_round_with_empty_expected_servers_reactivat chat.set_mcp_startup_expected_servers(std::iter::empty::()); chat.finish_mcp_startup(Vec::new(), Vec::new()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "runtime".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "runtime", McpServerStartupState::Starting); assert!(drain_insert_history(&mut rx).is_empty()); assert!(chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "runtime".to_string(), - status: McpServerStartupState::Failed, - error: Some("MCP client for `runtime` failed to start: handshake failed".to_string()), - }), - /*replay_kind*/ None, + notify_mcp_status_error( + &mut chat, + "runtime", + "MCP client for `runtime` failed to start: handshake failed", ); let summary_text = drain_insert_history(&mut rx) @@ -511,56 +343,17 @@ async fn app_server_mcp_startup_next_round_with_empty_expected_servers_reactivat assert!(!chat.bottom_pane.is_task_running()); } -#[tokio::test] -async fn app_server_mcp_startup_after_lag_with_empty_expected_servers_preserves_failures() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.show_welcome_banner = false; - chat.set_mcp_startup_expected_servers(std::iter::empty::()); - - chat.on_mcp_startup_update(McpStartupUpdateEvent { - server: "runtime".to_string(), - status: McpStartupStatus::Starting, - }); - chat.on_mcp_startup_update(McpStartupUpdateEvent { - server: "runtime".to_string(), - status: McpStartupStatus::Failed { - error: "MCP client for `runtime` failed to start: handshake failed".to_string(), - }, - }); - - let warning_text = drain_insert_history(&mut rx) - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::(); - assert!(warning_text.contains("MCP client for `runtime` failed to start: handshake failed")); - assert!(chat.bottom_pane.is_task_running()); - - chat.finish_mcp_startup_after_lag(); - - let summary_text = drain_insert_history(&mut rx) - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::(); - assert!(summary_text.contains("MCP startup incomplete (failed: runtime)")); - assert!(!chat.bottom_pane.is_task_running()); -} - #[tokio::test] async fn app_server_mcp_startup_after_lag_includes_runtime_servers_with_expected_set() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; chat.set_mcp_startup_expected_servers(["alpha".to_string()]); - chat.on_mcp_startup_update(McpStartupUpdateEvent { - server: "alpha".to_string(), - status: McpStartupStatus::Ready, - }); - chat.on_mcp_startup_update(McpStartupUpdateEvent { - server: "runtime".to_string(), - status: McpStartupStatus::Failed { - error: "MCP client for `runtime` failed to start: handshake failed".to_string(), - }, - }); + notify_mcp_status_error( + &mut chat, + "runtime", + "MCP client for `runtime` failed to start: handshake failed", + ); let warning_text = drain_insert_history(&mut rx) .iter() @@ -585,57 +378,32 @@ async fn app_server_mcp_startup_next_round_after_lag_can_settle_without_starting chat.show_welcome_banner = false; chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, - ); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), - }), - /*replay_kind*/ None, - ); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Starting, - error: None, - }), - /*replay_kind*/ None, + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); let _ = drain_insert_history(&mut rx); chat.finish_mcp_startup_after_lag(); let _ = drain_insert_history(&mut rx); assert!(!chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some( - "MCP client for `alpha` failed to start: stale handshake failed".to_string(), - ), - }), - /*replay_kind*/ None, + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: stale handshake failed", ); assert!(drain_insert_history(&mut rx).is_empty()); chat.finish_mcp_startup_after_lag(); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "alpha".to_string(), - status: McpServerStartupState::Failed, - error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), - }), - /*replay_kind*/ None, + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", ); let failure_text = drain_insert_history(&mut rx) @@ -645,14 +413,7 @@ async fn app_server_mcp_startup_next_round_after_lag_can_settle_without_starting assert!(failure_text.is_empty()); assert!(!chat.bottom_pane.is_task_running()); - chat.handle_server_notification( - ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { - name: "beta".to_string(), - status: McpServerStartupState::Ready, - error: None, - }), - /*replay_kind*/ None, - ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); let summary_text = drain_insert_history(&mut rx) .iter() diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index f2d8e2d0c..09595a11b 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); @@ -315,7 +356,7 @@ async fn approvals_popup_navigation_skips_disabled() { assert!( app_events.iter().any(|ev| matches!( ev, - AppEvent::CodexOp(AppCommand::OverrideTurnContext { + AppEvent::CodexOp(Op::OverrideTurnContext { approval_policy: Some(AskForApproval::OnRequest), personality: None, .. @@ -326,7 +367,7 @@ async fn approvals_popup_navigation_skips_disabled() { assert!( !app_events.iter().any(|ev| matches!( ev, - AppEvent::CodexOp(AppCommand::OverrideTurnContext { + AppEvent::CodexOp(Op::OverrideTurnContext { approval_policy: Some(AskForApproval::Never), personality: None, .. @@ -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 @@ -530,27 +571,25 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure .features .set_enabled(Feature::GuardianApproval, /*enabled*/ true); - chat.handle_codex_event(Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::OnRequest, - approvals_reviewer: ApprovalsReviewer::AutoReview, - permission_profile: PermissionProfile::workspace_write(), - active_permission_profile: None, - cwd: test_project_path().abs(), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), + chat.handle_thread_session(crate::session_state::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(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::AutoReview, + permission_profile: PermissionProfile::workspace_write(), + active_permission_profile: None, + cwd: test_project_path().abs(), + instruction_source_paths: Vec::new(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + network_proxy: None, + rollout_path: Some(PathBuf::new()), }); chat.open_permissions_popup(); @@ -578,34 +617,27 @@ 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(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::OnRequest, - approvals_reviewer: ApprovalsReviewer::AutoReview, - permission_profile, - active_permission_profile: None, - cwd, - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), + chat.handle_thread_session(crate::session_state::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(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::AutoReview, + permission_profile, + active_permission_profile: None, + cwd, + instruction_source_paths: Vec::new(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + network_proxy: None, + rollout_path: Some(PathBuf::new()), }); chat.open_permissions_popup(); @@ -630,7 +662,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 @@ -670,7 +702,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 @@ -699,7 +731,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context let op = std::iter::from_fn(|| rx.try_recv().ok()) .find_map(|event| match event { - AppEvent::CodexOp(op @ AppCommand::OverrideTurnContext { .. }) => Some(op), + AppEvent::CodexOp(op @ Op::OverrideTurnContext { .. }) => Some(op), _ => None, }) .expect("expected OverrideTurnContext op"); @@ -710,7 +742,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 143afab52..9f6f390ad 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -582,16 +582,17 @@ async fn request_user_input_notification_overrides_pending_agent_turn_complete_n chat.notify(Notification::AgentTurnComplete { response: "done".to_string(), }); - chat.handle_request_user_input_now(RequestUserInputEvent { - call_id: "call-1".to_string(), + chat.handle_request_user_input_now(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-1".to_string(), turn_id: "turn-1".to_string(), - questions: vec![RequestUserInputQuestion { + questions: vec![ToolRequestUserInputQuestion { id: "reasoning_scope".to_string(), header: "Reasoning scope".to_string(), question: "Which reasoning scope should I use?".to_string(), is_other: false, is_secret: false, - options: Some(vec![RequestUserInputQuestionOption { + options: Some(vec![ToolRequestUserInputOption { label: "Plan only".to_string(), description: "Update only Plan mode.".to_string(), }]), @@ -610,16 +611,17 @@ async fn handle_request_user_input_sets_pending_notification() { chat.config.tui_notifications.notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); - chat.handle_request_user_input_now(RequestUserInputEvent { - call_id: "call-1".to_string(), + chat.handle_request_user_input_now(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-1".to_string(), turn_id: "turn-1".to_string(), - questions: vec![RequestUserInputQuestion { + questions: vec![ToolRequestUserInputQuestion { id: "reasoning_scope".to_string(), header: "Reasoning scope".to_string(), question: "Which reasoning scope should I use?".to_string(), is_other: false, is_secret: false, - options: Some(vec![RequestUserInputQuestionOption { + options: Some(vec![ToolRequestUserInputOption { label: "Plan only".to_string(), description: "Update only Plan mode.".to_string(), }]), @@ -802,13 +804,23 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { .expect("expected plan collaboration mask"); chat.set_collaboration_mask(plan_mask); - chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Plan details".to_string()), - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - })]); + chat.replay_thread_turns( + vec![AppServerTurn { + id: "turn-1".to_string(), + items: vec![AppServerThreadItem::AgentMessage { + id: "msg-plan".to_string(), + text: "Plan details".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: None, + }], + status: AppServerTurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + ReplayKind::ResumeInitialMessages, + ); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -829,29 +841,36 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.on_plan_delta("- Step 1\n- Step 2\n".to_string()); chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string()); - chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Plan details".to_string()), - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - })]); + chat.replay_thread_turns( + vec![AppServerTurn { + id: "turn-1".to_string(), + items: vec![AppServerThreadItem::AgentMessage { + id: "msg-plan-replay".to_string(), + text: "Plan details".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: None, + }], + status: AppServerTurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + ReplayKind::ResumeInitialMessages, + ); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( !replay_popup.contains(PLAN_IMPLEMENTATION_TITLE), "expected no prompt for replayed turn completion, got {replay_popup:?}" ); - chat.handle_codex_event(Event { - id: "live-turn-complete-1".to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Plan details".to_string()), - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + complete_assistant_message( + &mut chat, + "msg-plan-live-1", + "Plan details", + Some(MessagePhase::FinalAnswer), + ); + handle_turn_completed(&mut chat, "live-turn-complete-1", /*duration_ms*/ None); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -866,16 +885,13 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com "expected prompt to dismiss on Esc, got {dismissed_popup:?}" ); - chat.handle_codex_event(Event { - id: "live-turn-complete-2".to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Plan details".to_string()), - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + complete_assistant_message( + &mut chat, + "msg-plan-live-2", + "Plan details", + Some(MessagePhase::FinalAnswer), + ); + handle_turn_completed(&mut chat, "live-turn-complete-2", /*duration_ms*/ None); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( !duplicate_popup.contains(PLAN_IMPLEMENTATION_TITLE), @@ -1137,15 +1153,13 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { other => panic!("expected running-turn compact steer submit, got {other:?}"), } - chat.handle_codex_event(Event { - id: "steer-rejected".into(), - msg: EventMsg::Error(ErrorEvent { - message: "cannot steer a compact turn".to_string(), - codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, - }), + handle_error( + &mut chat, + "cannot steer a compact turn", + Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, }), - }); + ); assert!(chat.pending_steers.is_empty()); assert_eq!( @@ -1186,9 +1200,10 @@ 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 { - session_id: conversation_id, + let configured = crate::session_state::ThreadSessionState { + thread_id: conversation_id, forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -1198,17 +1213,14 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); chat.bottom_pane .set_plugin_mentions(Some(vec![codex_plugin::PluginCapabilitySummary { @@ -1432,9 +1444,10 @@ 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 { - session_id: ThreadId::new(), + let configured = crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), forked_from_id: None, + fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), @@ -1444,17 +1457,14 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, - initial_messages: None, network_proxy: None, rollout_path: None, }; - chat.handle_codex_event(Event { - id: "configured".into(), - msg: EventMsg::SessionConfigured(configured), - }); + chat.handle_thread_session(configured); chat.bottom_pane .set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new()); @@ -1654,10 +1664,7 @@ async fn plan_update_renders_history_cell() { }, ], }; - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::PlanUpdate(update), - }); + chat.on_plan_update(update); let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected plan update cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); 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 dd318db13..52b90f33f 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -9,12 +9,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()), }); @@ -2323,13 +2325,11 @@ async fn server_overloaded_error_does_not_switch_models() { while rx.try_recv().is_ok() {} while op_rx.try_recv().is_ok() {} - chat.handle_codex_event(Event { - id: "err-1".to_string(), - msg: EventMsg::Error(ErrorEvent { - message: "server overloaded".to_string(), - codex_error_info: Some(CodexErrorInfo::ServerOverloaded), - }), - }); + handle_error( + &mut chat, + "server overloaded", + Some(CodexErrorInfo::ServerOverloaded), + ); while let Ok(event) = rx.try_recv() { if let AppEvent::UpdateModel(model) = event { diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 720b17c18..d44918eb0 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -62,15 +62,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { // When interrupted, queued messages are merged into the composer; image placeholders // 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 { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_interrupted(&mut chat, "turn-1"); let first = "[Image #1] first".to_string(); let second = "[Image #2] second".to_string(); @@ -111,15 +103,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { async fn entered_review_mode_uses_request_hint() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - 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()), - }), - }); + handle_entered_review_mode(&mut chat, "feature branch"); let cells = drain_insert_history(&mut rx); let banner = lines_to_single_string(cells.last().expect("review banner")); @@ -132,13 +116,7 @@ async fn entered_review_mode_uses_request_hint() { async fn entered_review_mode_defaults_to_current_changes_banner() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }), - }); + handle_entered_review_mode(&mut chat, "current changes"); let cells = drain_insert_history(&mut rx); let banner = lines_to_single_string(cells.last().expect("review banner")); @@ -147,18 +125,10 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { } #[tokio::test] -async fn live_core_review_prompt_item_is_not_rendered() { +async fn live_review_prompt_item_is_not_rendered() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - 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()), - }), - }); + handle_entered_review_mode(&mut chat, "changes against 'main'"); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); assert!(lines_to_single_string(&cells[0]).contains("Code review started")); @@ -224,24 +194,8 @@ async fn live_app_server_review_prompt_item_is_not_rendered() { async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); - 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()), - }), - }); + handle_turn_started(&mut chat, "turn-1"); + handle_entered_review_mode(&mut chat, "feature branch"); let _ = drain_insert_history(&mut rx); chat.queued_user_messages .push_back(UserMessage::from("queued later").into()); @@ -271,24 +225,20 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages other => panic!("expected second running-turn steer submit, got {other:?}"), } - chat.handle_codex_event(Event { - id: "steer-rejected-1".into(), - msg: EventMsg::Error(ErrorEvent { - message: "cannot steer a review turn".to_string(), - codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, - }), + handle_error( + &mut chat, + "cannot steer a review turn", + Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, }), - }); - chat.handle_codex_event(Event { - id: "steer-rejected-2".into(), - msg: EventMsg::Error(ErrorEvent { - message: "cannot steer a review turn".to_string(), - codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, - }), + ); + handle_error( + &mut chat, + "cannot steer a review turn", + Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, }), - }); + ); assert!(chat.pending_steers.is_empty()); assert_eq!( @@ -301,22 +251,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages ); assert!(drain_insert_history(&mut rx).is_empty()); - chat.handle_codex_event(Event { - id: "review-exit".into(), - msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: None, - }), - }); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_exited_review_mode(&mut chat); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( @@ -329,16 +265,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"), } - chat.handle_codex_event(Event { - id: "turn-complete-2".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-2".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( @@ -356,23 +283,15 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages async fn live_agent_message_renders_during_review_mode() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }), - }); + handle_entered_review_mode(&mut chat, "current changes"); let _ = drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "review-message".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Review progress update".to_string(), - phase: None, - memory_citation: None, - }), - }); + complete_assistant_message( + &mut chat, + "review-message", + "Review progress update", + /*phase*/ None, + ); let inserted = drain_insert_history(&mut rx); assert_eq!(inserted.len(), 1); @@ -388,40 +307,21 @@ async fn review_restores_context_window_indicator() { let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline. let review_tokens = 12_030; // ~97% remaining after subtracting baseline. - chat.handle_codex_event(Event { - id: "token-before".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(make_token_info(pre_review_tokens, context_window)), - rate_limits: None, - }), - }); + handle_token_count( + &mut chat, + Some(make_token_info(pre_review_tokens, context_window)), + ); assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); - 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()), - }), - }); + handle_entered_review_mode(&mut chat, "feature branch"); - chat.handle_codex_event(Event { - id: "token-review".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(make_token_info(review_tokens, context_window)), - rate_limits: None, - }), - }); + handle_token_count( + &mut chat, + Some(make_token_info(review_tokens, context_window)), + ); assert_eq!(chat.bottom_pane.context_window_percent(), Some(97)); - chat.handle_codex_event(Event { - id: "review-end".into(), - msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: None, - }), - }); + handle_exited_review_mode(&mut chat); let _ = drain_insert_history(&mut rx); assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); @@ -651,7 +551,7 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements() "user-1", vec![ UserInput::Image { - image_url: "data:image/png;base64,placeholder".to_string(), + url: "data:image/png;base64,placeholder".to_string(), }, UserInput::Text { text, @@ -932,10 +832,9 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() items, vec![UserInput::Text { text: "please use $figma".to_string(), - text_elements: vec![TextElement::new( - (11..17).into(), - Some("$figma".to_string()), - )], + text_elements: vec![ + TextElement::new((11..17).into(), Some("$figma".to_string())).into() + ], }] ), other => panic!("expected Op::UserTurn, got {other:?}"), @@ -990,61 +889,6 @@ queued draft" assert_no_submit_op(&mut op_rx); } -#[tokio::test] -async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.thread_id = Some(ThreadId::new()); - chat.on_task_started(); - chat.on_agent_message_delta( - "Final answer line -" - .to_string(), - ); - - chat.bottom_pane - .set_composer_text("pending steer".to_string(), Vec::new(), Vec::new()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - chat.queued_user_messages - .push_back(UserMessage::from("queued draft".to_string()).into()); - chat.refresh_pending_input_preview(); - - match next_submit_op(&mut op_rx) { - Op::UserTurn { items, .. } => assert_eq!( - items, - vec![UserInput::Text { - text: "pending steer".to_string(), - text_elements: Vec::new(), - }] - ), - other => panic!("expected Op::UserTurn, got {other:?}"), - } - assert!(drain_insert_history(&mut rx).is_empty()); - - chat.handle_codex_event(Event { - id: "replaced".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Replaced, - completed_at: None, - duration_ms: None, - }), - }); - - assert!(chat.pending_steers.is_empty()); - assert!(chat.queued_user_messages.is_empty()); - assert_eq!(chat.bottom_pane.composer_text(), ""); - match next_submit_op(&mut op_rx) { - Op::UserTurn { items, .. } => assert_eq!( - items, - vec![UserInput::Text { - text: "queued draft".to_string(), - text_elements: Vec::new(), - }] - ), - other => panic!("expected queued draft Op::UserTurn, got {other:?}"), - } -} - #[tokio::test] async fn ctrl_c_shutdown_works_with_caps_lock() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1221,17 +1065,14 @@ async fn custom_prompt_submit_sends_review_op() { chat.handle_paste(" please audit dependencies ".to_string()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - // Expect AppEvent::CodexOp(AppCommand::Review { .. }) with trimmed prompt + // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt let evt = rx.try_recv().expect("expected one app event"); match evt { - AppEvent::CodexOp(AppCommand::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(), } ); } @@ -1263,15 +1104,7 @@ async fn interrupt_exec_marks_failed_snapshot() { // Simulate the task being aborted (as if ESC was pressed), which should // 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 { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_interrupted(&mut chat, "turn-1"); let cells = drain_insert_history(&mut rx); assert!( @@ -1291,26 +1124,10 @@ async fn interrupted_turn_error_message_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; // Simulate an in-progress task so the widget is in a running state. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); // 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 { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_interrupted(&mut chat, "turn-1"); let cells = drain_insert_history(&mut rx); assert!( @@ -1389,24 +1206,8 @@ async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot async fn direct_budget_limited_turn_uses_budget_message_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::BudgetLimited, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_started(&mut chat, "turn-1"); + handle_budget_limited_turn(&mut chat, "turn-1"); let cells = drain_insert_history(&mut rx); let last = lines_to_single_string(cells.last().unwrap()); @@ -1420,24 +1221,8 @@ async fn budget_limited_turn_restores_queued_input_without_submitting() { .push_back(UserMessage::from("follow-up after budget stop").into()); chat.refresh_pending_input_preview(); - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::BudgetLimited, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_started(&mut chat, "turn-1"); + handle_budget_limited_turn(&mut chat, "turn-1"); assert!(chat.queued_user_messages.is_empty()); assert_eq!( @@ -1457,25 +1242,9 @@ async fn interrupted_turn_pending_steers_message_snapshot() { chat.pending_steers.push_back(pending_steer("steer 1")); chat.submit_pending_steers_after_interrupt = true; - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_interrupted(&mut chat, "turn-1"); let cells = drain_insert_history(&mut rx); let info = cells @@ -1557,66 +1326,13 @@ async fn review_branch_picker_escape_navigates_back_then_dismisses() { ); } -#[tokio::test] -async fn review_ended_keeps_unified_exec_processes() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); - begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); - assert_eq!(chat.unified_exec_processes.len(), 2); - - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::ReviewEnded, - completed_at: None, - duration_ms: None, - }), - }); - - assert_eq!(chat.unified_exec_processes.len(), 2); - - chat.add_ps_output(); - let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::>() - .join("\n"); - assert!( - combined.contains("Background terminals"), - "expected /ps to remain available after review-ended abort; got {combined:?}" - ); - assert!( - combined.contains("sleep 5") && combined.contains("sleep 6"), - "expected /ps to list running unified exec processes; got {combined:?}" - ); - - let _ = drain_insert_history(&mut rx); -} - #[tokio::test] async fn enter_submits_steer_while_review_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); - chat.handle_codex_event(Event { - id: "review-1".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: Some("current changes".to_string()), - }), - }); + handle_entered_review_mode(&mut chat, "current changes"); let _ = drain_insert_history(&mut rx); chat.bottom_pane.set_composer_text( @@ -1649,37 +1365,21 @@ async fn enter_submits_steer_while_review_is_running() { async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); - chat.handle_codex_event(Event { - id: "review-1".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: Some("current changes".to_string()), - }), - }); + handle_entered_review_mode(&mut chat, "current changes"); let _ = drain_insert_history(&mut rx); chat.submit_user_message(UserMessage::from( "Steer submitted while /review was running.".to_string(), )); - chat.handle_codex_event(Event { - id: "steer-rejected".into(), - msg: EventMsg::Error(ErrorEvent { - message: "cannot steer a review turn".to_string(), - codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, - }), + handle_error( + &mut chat, + "cannot steer a review turn", + Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, }), - }); + ); let width: u16 = 80; let height: u16 = 18; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 54d45c848..53312a7f8 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,12 +1,16 @@ use super::*; use pretty_assertions::assert_eq; -fn turn_complete_event(turn_id: &str, last_agent_message: Option<&str>) -> TurnCompleteEvent { - serde_json::from_value(serde_json::json!({ - "turn_id": turn_id, - "last_agent_message": last_agent_message, - })) - .expect("turn complete event should deserialize") +fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) { + if let Some(message) = message { + complete_assistant_message( + chat, + &format!("{turn_id}-message"), + message, + Some(MessagePhase::FinalAnswer), + ); + } + handle_turn_completed(chat, turn_id, /*duration_ms*/ None); } fn submit_composer_text(chat: &mut ChatWidget, text: &str) { @@ -51,7 +55,7 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { assert!(chat.bottom_pane.is_task_running()); match rx.try_recv() { - Ok(AppEvent::CodexOp(AppCommand::Compact)) => {} + Ok(AppEvent::CodexOp(Op::Compact)) => {} other => panic!("expected compact op to be submitted, got {other:?}"), } @@ -75,15 +79,7 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { async fn queued_slash_compact_dispatches_after_active_turn() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/compact"); @@ -94,16 +90,13 @@ async fn queued_slash_compact_dispatches_after_active_turn() { ); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( events .iter() - .any(|event| matches!(event, AppEvent::CodexOp(AppCommand::Compact))), + .any(|event| matches!(event, AppEvent::CodexOp(Op::Compact))), "expected queued /compact to submit compact op; events: {events:?}" ); } @@ -112,43 +105,26 @@ async fn queued_slash_compact_dispatches_after_active_turn() { async fn queued_slash_review_with_args_dispatches_after_active_turn() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/review check regressions"); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); 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:?}"), @@ -159,15 +135,7 @@ async fn queued_slash_review_with_args_dispatches_after_active_turn() { async fn queued_slash_review_with_args_restores_for_edit() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/review check regressions"); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); @@ -182,15 +150,7 @@ async fn queued_slash_review_with_args_restores_for_edit() { async fn queued_bang_shell_dispatches_after_active_turn() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "!echo hi"); @@ -201,10 +161,7 @@ async fn queued_bang_shell_dispatches_after_active_turn() { ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); match op_rx.try_recv() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), @@ -221,25 +178,14 @@ async fn queued_bang_shell_dispatches_after_active_turn() { async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_input() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "!"); queue_composer_text_with_tab(&mut chat, "hello after help"); assert!(drain_insert_history(&mut rx).is_empty()); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); let cells = drain_insert_history(&mut rx); let rendered = cells @@ -269,23 +215,12 @@ async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_inpu async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "!echo hi"); queue_composer_text_with_tab(&mut chat, "hello after shell"); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); match op_rx.try_recv() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), @@ -321,23 +256,12 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, command); queue_composer_text_with_tab(&mut chat, "hello after menu"); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); assert_eq!(chat.queued_user_messages.len(), 1); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -373,23 +297,12 @@ async fn queued_slash_menu_cancel_drains_next_input() { async fn queued_slash_menu_selection_drains_next_input() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/permissions"); queue_composer_text_with_tab(&mut chat, "hello after selection"); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -417,23 +330,12 @@ async fn queued_bare_rename_drains_next_input_after_name_update() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/rename"); queue_composer_text_with_tab(&mut chat, "hello after rename"); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); assert_eq!(chat.queued_user_messages.len(), 1); assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Name thread")); @@ -446,18 +348,20 @@ async fn queued_bare_rename_drains_next_input_after_name_update() { assert!( events.iter().any(|event| matches!( event, - AppEvent::CodexOp(AppCommand::SetThreadName { name }) if name == "Queued rename" + AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename" )), "expected rename prompt to submit thread name; events: {events:?}" ); - chat.handle_codex_event(Event { - id: "rename".into(), - msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent { - thread_id, - thread_name: Some("Queued rename".to_string()), - }), - }); + chat.handle_server_notification( + ServerNotification::ThreadNameUpdated( + codex_app_server_protocol::ThreadNameUpdatedNotification { + thread_id: thread_id.to_string(), + thread_name: Some("Queued rename".to_string()), + }, + ), + /*replay_kind*/ None, + ); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( @@ -477,30 +381,19 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/rename Queued rename"); queue_composer_text_with_tab(&mut chat, "first after rename"); queue_composer_text_with_tab(&mut chat, "second after rename"); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( events.iter().any(|event| matches!( event, - AppEvent::CodexOp(AppCommand::SetThreadName { name }) if name == "Queued rename" + AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename" )), "expected queued /rename to submit thread name; events: {events:?}" ); @@ -534,13 +427,15 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() { vec!["second after rename"] ); - chat.handle_codex_event(Event { - id: "rename".into(), - msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent { - thread_id, - thread_name: Some("Queued rename".to_string()), - }), - }); + chat.handle_server_notification( + ServerNotification::ThreadNameUpdated( + codex_app_server_protocol::ThreadNameUpdatedNotification { + thread_id: thread_id.to_string(), + thread_name: Some("Queued rename".to_string()), + }, + ), + /*replay_kind*/ None, + ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); assert_eq!( @@ -548,19 +443,8 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() { vec!["second after rename"] ); - chat.handle_codex_event(Event { - id: "turn-2-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-2".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); - chat.handle_codex_event(Event { - id: "turn-2-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-2", Some("done"))), - }); + handle_turn_started(&mut chat, "turn-2"); + complete_turn_with_message(&mut chat, "turn-2", Some("done")); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( @@ -579,24 +463,13 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() { async fn queued_unknown_slash_reports_error_when_dequeued() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/does-not-exist"); assert!(drain_insert_history(&mut rx).is_empty()); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); let cells = drain_insert_history(&mut rx); let rendered = cells @@ -1137,7 +1010,7 @@ async fn slash_rename_prefills_existing_thread_name() { assert_matches!( rx.try_recv(), - Ok(AppEvent::CodexOp(AppCommand::SetThreadName { name })) if name == "Current project title" + Ok(AppEvent::CodexOp(Op::SetThreadName { name })) if name == "Current project title" ); } @@ -1259,16 +1132,7 @@ async fn slash_logout_requests_app_server_logout() { async fn slash_copy_state_tracks_turn_complete_final_reply() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Final reply **markdown**".to_string()), - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("Final reply **markdown**")); assert_eq!( chat.last_agent_markdown_text(), @@ -1281,27 +1145,18 @@ async fn slash_copy_state_tracks_plan_item_completion() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let plan_text = "## Plan\n\n1. Build it\n2. Test it".to_string(); - chat.handle_codex_event(Event { - id: "item-plan".into(), - msg: EventMsg::ItemCompleted(ItemCompletedEvent { - thread_id: ThreadId::new(), + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: String::new(), turn_id: "turn-1".to_string(), - item: TurnItem::Plan(PlanItem { + item: AppServerThreadItem::Plan { id: "plan-1".to_string(), text: plan_text.clone(), - }), + }, }), - }); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + /*replay_kind*/ None, + ); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); assert_eq!(chat.last_agent_markdown_text(), Some(plan_text.as_str())); assert_matches!( @@ -1438,16 +1293,7 @@ async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() { async fn slash_copy_state_is_preserved_during_running_task() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Previous completed reply".to_string()), - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("Previous completed reply")); chat.on_task_started(); assert_eq!( @@ -1456,50 +1302,11 @@ async fn slash_copy_state_is_preserved_during_running_task() { ); } -#[tokio::test] -async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omits_text() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.handle_codex_event_replay(Event { - id: "turn-1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Legacy final message".into(), - phase: None, - memory_citation: None, - }), - }); - let _ = drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); - let _ = drain_insert_history(&mut rx); - - assert_eq!( - chat.last_agent_markdown_text(), - Some("Legacy final message") - ); -} - #[tokio::test] async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); complete_assistant_message( &mut chat, "msg-1", @@ -1507,16 +1314,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( /*phase*/ None, ); let _ = drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); let _ = drain_insert_history(&mut rx); assert_eq!( @@ -1533,18 +1331,10 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Previous reply"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("Previous reply")); chat.pending_notification = None; - chat.handle_codex_event(Event { - id: "turn-2".into(), - msg: EventMsg::TurnComplete(turn_complete_event( - "turn-2", /*last_agent_message*/ None, - )), - }); + handle_turn_completed(&mut chat, "turn-2", /*duration_ms*/ None); assert_matches!( chat.pending_notification, @@ -1576,10 +1366,7 @@ async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notificati /*replay_kind*/ None, ); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("Still working")); assert_matches!(chat.pending_notification, None); } @@ -1588,21 +1375,10 @@ async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notificati async fn queued_follow_up_suppresses_agent_turn_complete_notification() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); chat.queue_user_message("Continue".into()); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("Still working")); assert_matches!(chat.pending_notification, None); assert!(chat.queued_user_messages.is_empty()); @@ -1613,21 +1389,10 @@ async fn queued_follow_up_suppresses_agent_turn_complete_notification() { async fn queued_menu_slash_keeps_agent_turn_complete_notification() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/model"); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("Done")); assert_matches!( chat.pending_notification, @@ -1641,40 +1406,20 @@ async fn queued_menu_slash_keeps_agent_turn_complete_notification() { async fn slash_copy_uses_latest_surviving_response_after_rollback() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event_replay(Event { - id: "user-1".into(), - msg: EventMsg::UserMessage(UserMessageEvent { - message: "foo".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }), - }); - chat.handle_codex_event_replay(Event { - id: "agent-1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "foo response".to_string(), - phase: None, - memory_citation: None, - }), - }); - chat.handle_codex_event_replay(Event { - id: "user-2".into(), - msg: EventMsg::UserMessage(UserMessageEvent { - message: "bar".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }), - }); - chat.handle_codex_event_replay(Event { - id: "agent-2".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "bar response".to_string(), - phase: None, - memory_citation: None, - }), - }); + replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot); + replay_agent_message( + &mut chat, + "agent-1", + "foo response", + ReplayKind::ThreadSnapshot, + ); + replay_user_message_text(&mut chat, "user-2", "bar", ReplayKind::ThreadSnapshot); + replay_agent_message( + &mut chat, + "agent-2", + "bar response", + ReplayKind::ThreadSnapshot, + ); let _ = drain_insert_history(&mut rx); assert_eq!(chat.last_agent_markdown_text(), Some("bar response")); @@ -1691,23 +1436,13 @@ async fn slash_copy_uses_latest_surviving_response_after_rollback() { async fn slash_copy_reports_when_rewind_exceeds_retained_copy_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event_replay(Event { - id: "user-1".into(), - msg: EventMsg::UserMessage(UserMessageEvent { - message: "foo".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }), - }); - chat.handle_codex_event_replay(Event { - id: "agent-1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "foo response".to_string(), - phase: None, - memory_citation: None, - }), - }); + replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot); + replay_agent_message( + &mut chat, + "agent-1", + "foo response", + ReplayKind::ThreadSnapshot, + ); let _ = drain_insert_history(&mut rx); chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 0); @@ -1954,97 +1689,6 @@ async fn slash_rollout_handles_missing_path() { ); } -#[tokio::test] -async fn undo_success_events_render_info_messages() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.handle_codex_event(Event { - id: "turn-1".to_string(), - msg: EventMsg::UndoStarted(UndoStartedEvent { - message: Some("Undo requested for the last turn...".to_string()), - }), - }); - assert!( - chat.bottom_pane.status_indicator_visible(), - "status indicator should be visible during undo" - ); - - chat.handle_codex_event(Event { - id: "turn-1".to_string(), - msg: EventMsg::UndoCompleted(UndoCompletedEvent { - success: true, - message: None, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected final status only"); - assert!( - !chat.bottom_pane.status_indicator_visible(), - "status indicator should be hidden after successful undo" - ); - - let completed = lines_to_single_string(&cells[0]); - assert!( - completed.contains("Undo completed successfully."), - "expected default success message, got {completed:?}" - ); -} - -#[tokio::test] -async fn undo_failure_events_render_error_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.handle_codex_event(Event { - id: "turn-2".to_string(), - msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), - }); - assert!( - chat.bottom_pane.status_indicator_visible(), - "status indicator should be visible during undo" - ); - - chat.handle_codex_event(Event { - id: "turn-2".to_string(), - msg: EventMsg::UndoCompleted(UndoCompletedEvent { - success: false, - message: Some("Failed to restore workspace state.".to_string()), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected final status only"); - assert!( - !chat.bottom_pane.status_indicator_visible(), - "status indicator should be hidden after failed undo" - ); - - let completed = lines_to_single_string(&cells[0]); - assert!( - completed.contains("Failed to restore workspace state."), - "expected failure message, got {completed:?}" - ); -} - -#[tokio::test] -async fn undo_started_hides_interrupt_hint() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - chat.handle_codex_event(Event { - id: "turn-hint".to_string(), - msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), - }); - - let status = chat - .bottom_pane - .status_widget() - .expect("status indicator should be active"); - assert!( - !status.interrupt_hint_visible(), - "undo should hide the interrupt hint because the operation cannot be cancelled" - ); -} - #[tokio::test] async fn fast_slash_command_updates_and_persists_local_service_tier() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; @@ -2056,7 +1700,7 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { assert!( events.iter().any(|event| matches!( event, - AppEvent::CodexOp(AppCommand::OverrideTurnContext { + AppEvent::CodexOp(Op::OverrideTurnContext { service_tier: Some(Some(ServiceTier::Fast)), .. }) @@ -2106,29 +1750,18 @@ async fn queued_fast_slash_applies_before_next_queued_message() { chat.thread_id = Some(ThreadId::new()); set_chatgpt_auth(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/fast on"); queue_composer_text_with_tab(&mut chat, "hello after fast"); - chat.handle_codex_event(Event { - id: "turn-complete".into(), - msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))), - }); + complete_turn_with_message(&mut chat, "turn-1", Some("done")); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( events.iter().any(|event| matches!( event, - AppEvent::CodexOp(AppCommand::OverrideTurnContext { + AppEvent::CodexOp(Op::OverrideTurnContext { service_tier: Some(Some(ServiceTier::Fast)), .. }) @@ -2167,7 +1800,7 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() { assert!( events.iter().any(|event| matches!( event, - AppEvent::CodexOp(AppCommand::OverrideTurnContext { + AppEvent::CodexOp(Op::OverrideTurnContext { service_tier: Some(None), .. }) @@ -2199,28 +1832,18 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() { async fn compact_queues_user_messages_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); chat.submit_user_message(UserMessage::from( "Steer submitted while /compact was running.".to_string(), )); - chat.handle_codex_event(Event { - id: "steer-rejected".into(), - msg: EventMsg::Error(ErrorEvent { - message: "cannot steer a compact turn".to_string(), - codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Compact, - }), + handle_error( + &mut chat, + "cannot steer a compact turn", + Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Compact, }), - }); + ); let width: u16 = 80; let height: u16 = 18; 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 530629b2a..8afbd8ad7 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2,7 +2,7 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; use pretty_assertions::assert_eq; -/// Receiving a TokenCount event without usage clears the context indicator. +/// Receiving a token usage update without usage clears the context indicator. #[tokio::test] async fn token_count_none_resets_context_indicator() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; @@ -10,36 +10,25 @@ async fn token_count_none_resets_context_indicator() { let context_window = 13_000; let pre_compact_tokens = 12_700; - chat.handle_codex_event(Event { - id: "token-before".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(make_token_info(pre_compact_tokens, context_window)), - rate_limits: None, - }), - }); + handle_token_count( + &mut chat, + Some(make_token_info(pre_compact_tokens, context_window)), + ); assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); - chat.handle_codex_event(Event { - id: "token-cleared".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: None, - rate_limits: None, - }), - }); + handle_token_count(&mut chat, /*info*/ None); assert_eq!(chat.bottom_pane.context_window_percent(), None); } #[tokio::test] -async fn core_cyber_policy_error_renders_dedicated_notice() { +async fn app_server_cyber_policy_error_renders_dedicated_notice() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "cyber-policy".into(), - msg: EventMsg::Error(ErrorEvent { - message: "server fallback message".to_string(), - codex_error_info: Some(CodexErrorInfo::CyberPolicy), - }), - }); + handle_error( + &mut chat, + "server fallback message", + Some(CodexErrorInfo::CyberPolicy), + ); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); @@ -50,15 +39,13 @@ async fn core_cyber_policy_error_renders_dedicated_notice() { } #[tokio::test] -async fn core_model_verification_renders_warning() { +async fn app_server_model_verification_renders_warning() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "model-verification".into(), - msg: EventMsg::ModelVerification(ModelVerificationEvent { - verifications: vec![CoreModelVerification::TrustedAccessForCyber], - }), - }); + handle_model_verification( + &mut chat, + vec![AppServerModelVerification::TrustedAccessForCyber], + ); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); @@ -89,13 +76,7 @@ async fn context_indicator_shows_used_tokens_when_window_unknown() { model_context_window: None, }; - chat.handle_codex_event(Event { - id: "token-usage".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(token_info), - rate_limits: None, - }), - }); + handle_token_count(&mut chat, Some(token_info)); assert_eq!(chat.bottom_pane.context_window_percent(), None); assert_eq!( @@ -105,20 +86,17 @@ async fn context_indicator_shows_used_tokens_when_window_unknown() { } #[tokio::test] -async fn turn_started_uses_runtime_context_window_before_first_token_count() { +async fn token_usage_update_uses_runtime_context_window() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.model_context_window = Some(1_000_000); - chat.handle_codex_event(Event { - id: "turn-start".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: Some(950_000), - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_token_count( + &mut chat, + Some(make_token_info( + /*total_tokens*/ 0, /*context_window*/ 950_000, + )), + ); assert_eq!( chat.status_line_value_for_item(&crate::bottom_pane::StatusLineItem::ContextWindowSize), @@ -146,7 +124,7 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() { assert!( context_line.contains("950K"), - "expected /status to use TurnStarted context window, got: {context_line}" + "expected /status to use runtime context window, got: {context_line}" ); assert!( !context_line.contains("1M"), @@ -279,8 +257,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 +292,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 +311,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 +330,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 +354,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 +372,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 +425,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, @@ -1099,21 +1077,8 @@ async fn ui_snapshots_small_heights_task_running() { use ratatui::backend::TestBackend; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; // Activate status line - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Thinking**".into(), - }), - }); + handle_turn_started(&mut chat, "turn-1"); + handle_agent_reasoning_delta(&mut chat, "**Thinking**"); for h in [1u16, 2, 3] { let name = format!("chat_small_running_h{h}"); let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); @@ -1129,26 +1094,13 @@ 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. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); // Provide a deterministic header for the status line. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); + handle_agent_reasoning_delta(&mut chat, "**Analyzing**"); // Now show an approval modal (e.g. exec approval). let ev = ExecApprovalRequestEvent { @@ -1161,19 +1113,14 @@ async fn status_widget_and_approval_modal_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), network_approval_context: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello world".into(), - ])), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment { + command: vec!["echo".into(), "hello world".into()], + }), proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-approve-exec".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); + handle_exec_approval_request(&mut chat, "sub-approve-exec", ev); // Render at the widget's desired height and snapshot. let width: u16 = 100; @@ -1196,22 +1143,9 @@ async fn status_widget_and_approval_modal_snapshot() { async fn status_widget_active_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; // Activate the status indicator by simulating a task start. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); // Provide a deterministic header via a bold reasoning chunk. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); + handle_agent_reasoning_delta(&mut chat, "**Analyzing**"); // Render and snapshot. let height = chat.desired_height(/*width*/ 80); let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) @@ -1231,14 +1165,7 @@ async fn stream_error_updates_status_indicator() { chat.bottom_pane.set_task_running(/*running*/ true); let msg = "Reconnecting... 2/5"; let details = "Idle timeout waiting for SSE"; - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::StreamError(StreamErrorEvent { - message: msg.to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: Some(details.to_string()), - }), - }); + handle_stream_error(&mut chat, msg, Some(details.to_string())); let cells = drain_insert_history(&mut rx); assert!( @@ -1264,14 +1191,7 @@ async fn stream_error_restores_hidden_status_indicator() { let msg = "Reconnecting... 2/5"; let details = "Idle timeout waiting for SSE"; - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::StreamError(StreamErrorEvent { - message: msg.to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: Some(details.to_string()), - }), - }); + handle_stream_error(&mut chat, msg, Some(details.to_string())); let status = chat .bottom_pane @@ -1284,12 +1204,7 @@ async fn stream_error_restores_hidden_status_indicator() { #[tokio::test] async fn warning_event_adds_warning_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::Warning(WarningEvent { - message: "test warning message".to_string(), - }), - }); + handle_warning(&mut chat, "test warning message"); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one warning history cell"); @@ -1398,16 +1313,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { chat.status_line_branch_lookup_complete = true; chat.status_line_branch_pending = false; - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); assert!(chat.status_line_branch_pending); } @@ -1419,15 +1325,7 @@ async fn status_line_branch_refreshes_after_interrupt() { chat.status_line_branch_lookup_complete = true; chat.status_line_branch_pending = false; - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - completed_at: None, - duration_ms: None, - }), - }); + handle_turn_interrupted(&mut chat, "turn-1"); assert!(chat.status_line_branch_pending); } @@ -1590,13 +1488,15 @@ async fn renamed_thread_footer_title_snapshot() { let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); - chat.handle_codex_event(Event { - id: "rename".to_string(), - msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent { - thread_id, - thread_name: Some("Roadmap cleanup".to_string()), - }), - }); + chat.handle_server_notification( + ServerNotification::ThreadNameUpdated( + codex_app_server_protocol::ThreadNameUpdatedNotification { + thread_id: thread_id.to_string(), + thread_name: Some("Roadmap cleanup".to_string()), + }, + ), + /*replay_kind*/ None, + ); let width = 80; let height = chat.desired_height(width); @@ -1779,27 +1679,25 @@ async fn session_configured_clears_goal_status_footer() { chat.budget_limited_turn_ids.insert("turn-1".to_string()); let rollout_file = NamedTempFile::new().unwrap(); - chat.handle_codex_event(Event { - id: "session-2".into(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - thread_name: None, - model: "gpt-5.4".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - permission_profile: PermissionProfile::read_only(), - active_permission_profile: None, - cwd: test_path_buf("/home/user/project").abs(), - reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(rollout_file.path().to_path_buf()), - }), + chat.handle_thread_session(crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-5.4".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_path_buf("/home/user/project").abs(), + instruction_source_paths: Vec::new(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + network_proxy: None, + rollout_path: Some(rollout_file.path().to_path_buf()), }); assert_eq!(chat.current_goal_status_indicator, None); @@ -1999,15 +1897,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; // Begin turn - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); // First finalized assistant message complete_assistant_message(&mut chat, "msg-first", "First message", /*phase*/ None); @@ -2021,16 +1911,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { ); // End turn - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); let cells = drain_insert_history(&mut rx); let combined: String = cells @@ -2055,12 +1936,7 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; // No deltas; only final reasoning followed by final message. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoning(AgentReasoningEvent { - text: "I will first analyze the request.".into(), - }), - }); + handle_agent_reasoning_final(&mut chat); complete_assistant_message( &mut chat, "msg-result", @@ -2085,53 +1961,21 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; // Stream some reasoning deltas first. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "I will ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "first analyze the ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "request.".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoning(AgentReasoningEvent { - text: "request.".into(), - }), - }); + handle_agent_reasoning_delta(&mut chat, "I will "); + handle_agent_reasoning_delta(&mut chat, "first analyze the "); + handle_agent_reasoning_delta(&mut chat, "request."); + handle_agent_reasoning_final(&mut chat); // Then stream answer deltas, followed by the exact same final message. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "Here is the ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "result.".into(), - }), - }); + handle_agent_message_delta(&mut chat, "Here is the "); + handle_agent_message_delta(&mut chat, "result."); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Here is the result.".into(), - phase: None, - memory_citation: None, - }), - }); + complete_assistant_message( + &mut chat, + "msg-result", + "Here is the result.", + /*phase*/ None, + ); // Snapshot the combined visible content to ensure we render as expected // when deltas are followed by the identical final message. @@ -2221,7 +2065,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", @@ -2232,7 +2076,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", @@ -2244,54 +2088,27 @@ async fn post_tool_use_hook_events_render_snapshot() { async fn completed_hook_with_no_entries_stays_out_of_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(Event { - id: "hook-1".into(), - msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { - turn_id: None, - run: codex_protocol::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, - source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), - source: codex_protocol::protocol::HookSource::User, - display_order: 0, - status: codex_protocol::protocol::HookRunStatus::Running, - status_message: None, - started_at: 1, - completed_at: None, - duration_ms: None, - entries: Vec::new(), - }, - }), - }); + handle_hook_started( + &mut chat, + hook_started_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + /*status_message*/ None, + ), + ); assert!(drain_insert_history(&mut rx).is_empty()); reveal_running_hooks(&mut chat); let running_snapshot = hook_live_and_history_snapshot(&chat, "running", ""); - chat.handle_codex_event(Event { - id: "hook-1".into(), - msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { - turn_id: None, - run: codex_protocol::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, - source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(), - source: codex_protocol::protocol::HookSource::User, - display_order: 0, - status: codex_protocol::protocol::HookRunStatus::Completed, - status_message: None, - started_at: 1, - completed_at: Some(2), - duration_ms: Some(1), - entries: Vec::new(), - }, - }), - }); + handle_hook_completed( + &mut chat, + hook_completed_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Completed, + Vec::new(), + ), + ); assert!(drain_insert_history(&mut rx).is_empty()); let completed_lingering_snapshot = @@ -2308,20 +2125,26 @@ async fn completed_hook_with_no_entries_stays_out_of_history() { async fn quiet_hook_linger_starts_when_delayed_redraw_reveals_hook() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(hook_started_event( - "post-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, - Some("checking output policy"), - )); + handle_hook_started( + &mut chat, + hook_started_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + Some("checking output policy"), + ), + ); assert!(drain_insert_history(&mut rx).is_empty()); 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, - Vec::new(), - )); + handle_hook_completed( + &mut chat, + hook_completed_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Completed, + Vec::new(), + ), + ); assert!(drain_insert_history(&mut rx).is_empty()); assert!( @@ -2336,24 +2159,30 @@ async fn quiet_hook_linger_starts_when_delayed_redraw_reveals_hook() { async fn blocked_and_failed_hooks_render_feedback_and_errors() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - 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, - 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, - text: "hook exited with code 7".to_string(), - }], - )); + handle_hook_completed( + &mut chat, + hook_completed_run( + "pre-tool-use:0:/tmp/hooks.json", + 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(), + }], + ), + ); + handle_hook_completed( + &mut chat, + hook_completed_run( + "post-tool-use:1:/tmp/hooks.json", + 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(), + }], + ), + ); let rendered = drain_insert_history(&mut rx) .iter() @@ -2376,23 +2205,29 @@ async fn blocked_and_failed_hooks_render_feedback_and_errors() { async fn completed_hook_with_output_flushes_immediately() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(hook_started_event( - "pre-tool-use:0:/tmp/hooks.json:tool-call-1", - codex_protocol::protocol::HookEventName::PreToolUse, - Some("checking command"), - )); + handle_hook_started( + &mut chat, + hook_started_run( + "pre-tool-use:0:/tmp/hooks.json:tool-call-1", + codex_app_server_protocol::HookEventName::PreToolUse, + Some("checking command"), + ), + ); reveal_running_hooks(&mut chat); let running_snapshot = hook_live_and_history_snapshot(&chat, "running", ""); - 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, - text: "command blocked by policy".to_string(), - }], - )); + handle_hook_completed( + &mut chat, + hook_completed_run( + "pre-tool-use:0:/tmp/hooks.json:tool-call-1", + 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(), + }], + ), + ); let history = drain_insert_history(&mut rx) .iter() .map(|lines| lines_to_single_string(lines)) @@ -2409,22 +2244,28 @@ async fn completed_hook_with_output_flushes_immediately() { async fn completed_hook_output_precedes_following_assistant_message() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(hook_started_event( - "pre-tool-use:0:/tmp/hooks.json:tool-call-1", - codex_protocol::protocol::HookEventName::PreToolUse, - Some("checking command"), - )); + handle_hook_started( + &mut chat, + hook_started_run( + "pre-tool-use:0:/tmp/hooks.json:tool-call-1", + 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, - text: "command blocked by policy".to_string(), - }], - )); + handle_hook_completed( + &mut chat, + hook_completed_run( + "pre-tool-use:0:/tmp/hooks.json:tool-call-1", + 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(), + }], + ), + ); complete_assistant_message( &mut chat, @@ -2461,26 +2302,35 @@ async fn completed_same_id_hook_output_survives_restart() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let hook_id = "stop:0:/tmp/hooks.json"; - chat.handle_codex_event(hook_started_event( - hook_id, - codex_protocol::protocol::HookEventName::Stop, - Some("checking stop condition"), - )); + handle_hook_started( + &mut chat, + hook_started_run( + hook_id, + 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, - text: "continue with more context".to_string(), - }], - )); - chat.handle_codex_event(hook_started_event( - hook_id, - codex_protocol::protocol::HookEventName::Stop, - Some("checking stop condition"), - )); + handle_hook_completed( + &mut chat, + hook_completed_run( + hook_id, + 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(), + }], + ), + ); + handle_hook_started( + &mut chat, + hook_started_run( + hook_id, + codex_app_server_protocol::HookEventName::Stop, + Some("checking stop condition"), + ), + ); reveal_running_hooks(&mut chat); let history = drain_insert_history(&mut rx) @@ -2505,11 +2355,14 @@ async fn identical_parallel_running_hooks_collapse_to_count() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; 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, - Some("checking command policy"), - )); + handle_hook_started( + &mut chat, + hook_started_run( + &format!("pre-tool-use:0:/tmp/hooks.json:{tool_call_id}"), + codex_app_server_protocol::HookEventName::PreToolUse, + Some("checking command policy"), + ), + ); } reveal_running_hooks(&mut chat); @@ -2526,30 +2379,39 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() { chat.set_status_header("Thinking".to_string()); chat.bottom_pane.ensure_status_indicator(); - chat.handle_codex_event(hook_started_event( - "pre-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PreToolUse, - Some("checking command policy"), - )); + handle_hook_started( + &mut chat, + hook_started_run( + "pre-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PreToolUse, + Some("checking command policy"), + ), + ); assert_eq!(chat.current_status.header, "Thinking"); reveal_running_hooks(&mut chat); let first_running_snapshot = hook_live_and_history_snapshot(&chat, "pre running", ""); - chat.handle_codex_event(hook_started_event( - "post-tool-use:1:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, - Some("checking output policy"), - )); + handle_hook_started( + &mut chat, + hook_started_run( + "post-tool-use:1:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + Some("checking output policy"), + ), + ); assert_eq!(chat.current_status.header, "Thinking"); reveal_running_hooks(&mut chat); let second_running_snapshot = hook_live_and_history_snapshot(&chat, "post running", ""); - chat.handle_codex_event(hook_completed_event( - "pre-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PreToolUse, - codex_protocol::protocol::HookRunStatus::Completed, - Vec::new(), - )); + handle_hook_completed( + &mut chat, + hook_completed_run( + "pre-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PreToolUse, + codex_app_server_protocol::HookRunStatus::Completed, + Vec::new(), + ), + ); assert_eq!(chat.current_status.header, "Thinking"); let older_completed_snapshot = hook_live_and_history_snapshot(&chat, "pre completed lingering", ""); @@ -2557,12 +2419,15 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() { let older_completed_expired_snapshot = hook_live_and_history_snapshot(&chat, "pre completed after linger", ""); - chat.handle_codex_event(hook_completed_event( - "post-tool-use:1:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, - codex_protocol::protocol::HookRunStatus::Completed, - Vec::new(), - )); + handle_hook_completed( + &mut chat, + hook_completed_run( + "post-tool-use:1:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Completed, + Vec::new(), + ), + ); assert_eq!(chat.current_status.header, "Thinking"); assert!(chat.bottom_pane.status_indicator_visible()); assert!(drain_insert_history(&mut rx).is_empty()); @@ -2585,11 +2450,14 @@ async fn running_hook_does_not_displace_active_exec_cell() { let begin = begin_exec(&mut chat, "call-1", "echo done"); let exec_running = active_blob(&chat); - chat.handle_codex_event(hook_started_event( - "post-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, - Some("checking output policy"), - )); + handle_hook_started( + &mut chat, + hook_started_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + Some("checking output policy"), + ), + ); reveal_running_hooks(&mut chat); let exec_and_hook_running = format!( "active exec:\n{}active hooks:\n{}", @@ -2604,12 +2472,15 @@ async fn running_hook_does_not_displace_active_exec_cell() { .collect::(); let hook_running_after_exec = active_hook_blob(&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, - Vec::new(), - )); + handle_hook_completed( + &mut chat, + hook_completed_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + codex_app_server_protocol::HookRunStatus::Completed, + Vec::new(), + ), + ); assert!(drain_insert_history(&mut rx).is_empty()); let quiet_hook_completed_lingering = active_hook_blob(&chat); expire_quiet_hook_linger(&mut chat); @@ -2633,11 +2504,14 @@ async fn hidden_active_hook_does_not_add_transcript_separator() { .expect("active exec transcript lines") .len(); - chat.handle_codex_event(hook_started_event( - "post-tool-use:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::PostToolUse, - Some("checking output policy"), - )); + handle_hook_started( + &mut chat, + hook_started_run( + "post-tool-use:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::PostToolUse, + Some("checking output policy"), + ), + ); let hidden_hook_transcript = chat .active_cell_transcript_lines(/*width*/ 80) .expect("active exec transcript lines"); @@ -2668,22 +2542,28 @@ async fn hidden_active_hook_does_not_add_transcript_separator() { async fn hook_completed_before_reveal_renders_completed_without_running_flash() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_codex_event(hook_started_event( - "session-start:0:/tmp/hooks.json", - codex_protocol::protocol::HookEventName::SessionStart, - Some("warming the shell"), - )); + handle_hook_started( + &mut chat, + hook_started_run( + "session-start:0:/tmp/hooks.json", + 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, - text: "session context".to_string(), - }], - )); + handle_hook_completed( + &mut chat, + hook_completed_run( + "session-start:0:/tmp/hooks.json", + 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(), + }], + ), + ); let history = drain_insert_history(&mut rx) .iter() @@ -2698,7 +2578,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", @@ -2706,64 +2586,52 @@ async fn session_start_hook_events_render_snapshot() { .await; } -fn hook_started_event( +fn hook_started_run( 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 { - turn_id: None, - run: hook_run_summary( - id, - event_name, - codex_protocol::protocol::HookRunStatus::Running, - status_message, - Vec::new(), - ), - }), - } +) -> codex_app_server_protocol::HookRunSummary { + hook_run_summary( + id, + event_name, + codex_app_server_protocol::HookRunStatus::Running, + status_message, + Vec::new(), + ) } -fn hook_completed_event( +fn hook_completed_run( id: &str, - event_name: codex_protocol::protocol::HookEventName, - status: codex_protocol::protocol::HookRunStatus, - entries: Vec, -) -> Event { - Event { - id: id.to_string(), - msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { - turn_id: None, - run: hook_run_summary( - id, event_name, status, /*status_message*/ None, entries, - ), - }), - } + event_name: codex_app_server_protocol::HookEventName, + status: codex_app_server_protocol::HookRunStatus, + entries: Vec, +) -> codex_app_server_protocol::HookRunSummary { + hook_run_summary( + id, event_name, status, /*status_message*/ None, entries, + ) } 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, } } @@ -2794,7 +2662,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { ); let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()]; - let parsed_cmd = vec![ + let parsed_cmd = [ ParsedCommand::Search { query: Some("Change Approved".into()), path: None, @@ -2806,55 +2674,44 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { path: "diff_render.rs".into(), }, ]; - let cwd = AbsolutePathBuf::current_dir().expect("current dir"); - chat.handle_codex_event(Event { - id: "c1".into(), - msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "c1".into(), - process_id: None, - turn_id: "turn-1".into(), - command: command.clone(), + let command_actions = parsed_cmd + .iter() + .cloned() + .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) + .collect::>(); + let cwd = chat.config.cwd.clone(); + handle_exec_begin( + &mut chat, + AppServerThreadItem::CommandExecution { + id: "c1".into(), + command: codex_shell_command::parse_command::shlex_join(&command), cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - }); - chat.handle_codex_event(Event { - id: "c1".into(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "c1".into(), process_id: None, - turn_id: "turn-1".into(), - command, - cwd, - parsed_cmd, source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 0, - duration: std::time::Duration::from_millis(16000), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - }); - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Investigating rendering code**".into(), - }), - }); + status: AppServerCommandExecutionStatus::InProgress, + command_actions: command_actions.clone(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + }, + ); + handle_exec_end( + &mut chat, + AppServerThreadItem::CommandExecution { + id: "c1".into(), + command: codex_shell_command::parse_command::shlex_join(&command), + cwd, + process_id: None, + source: ExecCommandSource::Agent, + status: AppServerCommandExecutionStatus::Completed, + command_actions, + aggregated_output: None, + exit_code: Some(0), + duration_ms: Some(16000), + }, + ); + handle_turn_started(&mut chat, "turn-1"); + handle_agent_reasoning_delta(&mut chat, "**Investigating rendering code**"); chat.bottom_pane.set_composer_text( "Summarize recent commits".to_string(), Vec::new(), @@ -2893,15 +2750,7 @@ async fn chatwidget_markdown_code_blocks_vt100_snapshot() { // Simulate a final agent message via streaming deltas instead of a single message - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); // Build a vt100 visual from the history insertions only (no UI overlay) let width: u16 = 80; let height: u16 = 50; @@ -2944,10 +2793,7 @@ printf 'fenced within fenced\n' delta.push(c2); } - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), - }); + handle_agent_message_delta(&mut chat, delta); // Drive commit ticks and drain emitted history lines into the vt100 buffer. loop { chat.on_commit_tick(); @@ -2967,16 +2813,7 @@ printf 'fenced within fenced\n' } // Finalize the stream without sending a final AgentMessage, to flush any tail. - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - completed_at: None, - duration_ms: None, - time_to_first_token_ms: None, - }), - }); + handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); for lines in drain_insert_history(&mut rx) { crate::insert_history::insert_history_lines(&mut term, lines) .expect("Failed to insert history lines in test"); @@ -2992,15 +2829,7 @@ printf 'fenced within fenced\n' async fn chatwidget_tall() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + handle_turn_started(&mut chat, "turn-1"); for i in 0..30 { chat.queue_user_message(format!("Hello, world! {i}").into()); } diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index 724f7d26d..317942485 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -21,12 +21,8 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-action-required".into(), - msg: EventMsg::ExecApprovalRequest(request), - }); + handle_exec_approval_request(&mut chat, "sub-action-required", request); chat.pre_draw_tick(); @@ -67,12 +63,8 @@ async fn terminal_title_action_required_respects_spinner_setting() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-no-spinner".into(), - msg: EventMsg::ExecApprovalRequest(request), - }); + handle_exec_approval_request(&mut chat, "sub-no-spinner", request); chat.pre_draw_tick(); @@ -99,12 +91,8 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() { proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-blink".into(), - msg: EventMsg::ExecApprovalRequest(request), - }); + handle_exec_approval_request(&mut chat, "sub-blink", request); chat.pre_draw_tick(); @@ -138,12 +126,8 @@ async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_d proposed_network_policy_amendments: None, additional_permissions: None, available_decisions: None, - parsed_cmd: vec![], }; - chat.handle_codex_event(Event { - id: "sub-no-animations".into(), - msg: EventMsg::ExecApprovalRequest(request), - }); + handle_exec_approval_request(&mut chat, "sub-no-animations", request); chat.pre_draw_tick(); diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index d834f74d4..1d4bf2b85 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_render.rs b/codex-rs/tui/src/diff_render.rs index dc420a7d7..350c3c7aa 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 878d42c71..cd3f56166 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 423217a6f..7c1b533ac 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 803cfcde5..dd85348fa 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; @@ -20,13 +21,13 @@ use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::legacy_core::config::Config; -use crate::legacy_core::web_search_detail; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; 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)] @@ -43,32 +44,33 @@ 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_app_server_protocol::ToolRequestUserInputAnswer; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_app_server_protocol::WebSearchAction; use codex_config::types::McpServerTransportConfig; #[cfg(test)] use codex_mcp::qualified_mcp_tool_name_prefix; use codex_otel::RuntimeMetricsSummary; use codex_protocol::account::PlanType; +use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::approvals::NetworkPolicyAmendment; #[cfg(test)] 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; 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; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::format_env_display; @@ -860,13 +862,28 @@ fn exec_snippet(command: &[String]) -> String { truncate_exec_snippet(&full_cmd) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ReviewDecision { + Approved, + ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment, + }, + ApprovedForSession, + NetworkPolicyAmendment { + network_policy_amendment: NetworkPolicyAmendment, + }, + Denied, + TimedOut, + Abort, +} + pub fn new_approval_decision_cell( command: Vec, - decision: codex_protocol::protocol::ReviewDecision, + decision: ReviewDecision, actor: ApprovalDecisionActor, ) -> Box { - use codex_protocol::protocol::NetworkPolicyRuleAction; - use codex_protocol::protocol::ReviewDecision::*; + use ReviewDecision::*; + use codex_protocol::approvals::NetworkPolicyRuleAction; let (symbol, summary): (Span<'static>, Vec>) = match decision { Approved => { @@ -1231,28 +1248,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 +1311,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 +1326,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 +1335,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, @@ -1555,6 +1578,13 @@ pub(crate) struct McpToolCallCell { animations_enabled: bool, } +#[derive(Debug, Clone)] +pub(crate) struct McpInvocation { + pub(crate) server: String, + pub(crate) tool: String, + pub(crate) arguments: Option, +} + impl McpToolCallCell { pub(crate) fn new( call_id: String, @@ -1746,6 +1776,42 @@ fn web_search_header(completed: bool) -> &'static str { } } +fn web_search_action_detail(action: &WebSearchAction) -> String { + match action { + WebSearchAction::Search { query, queries } => { + query.clone().filter(|q| !q.is_empty()).unwrap_or_else(|| { + let items = queries.as_ref(); + let first = items + .and_then(|queries| queries.first()) + .cloned() + .unwrap_or_default(); + if items.is_some_and(|queries| queries.len() > 1) && !first.is_empty() { + format!("{first} ...") + } else { + first + } + }) + } + WebSearchAction::OpenPage { url } => url.clone().unwrap_or_default(), + WebSearchAction::FindInPage { url, pattern } => match (pattern, url) { + (Some(pattern), Some(url)) => format!("'{pattern}' in {url}"), + (Some(pattern), None) => format!("'{pattern}'"), + (None, Some(url)) => url.clone(), + (None, None) => String::new(), + }, + WebSearchAction::Other => String::new(), + } +} + +fn web_search_detail(action: Option<&WebSearchAction>, query: &str) -> String { + let detail = action.map(web_search_action_detail).unwrap_or_default(); + if detail.is_empty() { + query.to_string() + } else { + detail + } +} + #[derive(Debug)] pub(crate) struct WebSearchCell { call_id: String, @@ -2041,7 +2107,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 +2291,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 { @@ -2415,8 +2493,8 @@ pub(crate) fn new_mcp_inventory_loading(animations_enabled: bool) -> McpInventor /// Renders a completed (or interrupted) request_user_input exchange in history. #[derive(Debug)] pub(crate) struct RequestUserInputResultCell { - pub(crate) questions: Vec, - pub(crate) answers: HashMap, + pub(crate) questions: Vec, + pub(crate) answers: HashMap, pub(crate) interrupted: bool, } @@ -2540,7 +2618,7 @@ fn wrap_with_prefix( /// Split a request_user_input answer into option labels and an optional freeform note. /// Notes are encoded as "user_note: " entries in the answers list. fn split_request_user_input_answer( - answer: &RequestUserInputAnswer, + answer: &ToolRequestUserInputAnswer, ) -> (Vec, Option) { let mut options = Vec::new(); let mut note = None; @@ -2992,18 +3070,17 @@ 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; use codex_otel::RuntimeMetricsSummary; use codex_protocol::ThreadId; 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 +3089,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 +3260,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(), @@ -3196,10 +3274,10 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, 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()), } @@ -3297,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), @@ -3319,7 +3397,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), @@ -3336,7 +3414,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), @@ -3355,7 +3433,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), @@ -4216,10 +4294,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, @@ -4229,9 +4308,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 138a17b5a..c44d353c4 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 39b4294b4..334e412c0 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,13 @@ 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; use std::sync::Arc; +pub use token_usage::TokenUsage; use tracing::Level; use tracing::error; use tracing::warn; @@ -88,6 +83,7 @@ mod app_event; mod app_event_sender; mod app_server_approval_conversions; mod app_server_session; +mod approval_events; mod ascii_animation; #[cfg(not(target_os = "linux"))] mod audio_device; @@ -117,6 +113,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 +154,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,6 +167,7 @@ mod terminal_palette; mod terminal_title; mod text_formatting; mod theme_picker; +mod token_usage; mod tooltips; mod transcript_reflow; mod tui; @@ -189,7 +189,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 +231,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()) } @@ -482,7 +485,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 +704,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 +1082,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 +1159,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 +1204,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 +1265,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 +1326,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 +1371,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 +1476,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,19 +1649,12 @@ 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; @@ -2031,17 +1911,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<()> { @@ -2081,60 +1950,65 @@ mod tests { #[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, "")?; + Box::pin(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(), - ) + 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, + 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); + + app_server.shutdown().await?; + Ok(()) + }) .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 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(()) } #[tokio::test] @@ -2204,118 +2078,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 +2111,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 +2126,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 +2175,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 35230b61d..8b66e5368 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -19,7 +19,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, /*message*/ 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, /*message*/ 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, /*message*/ 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, /*message*/ 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 63373b755..a5fd4cea4 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1018,7 +1018,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; @@ -1049,7 +1048,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 7878ea855..68798ecc0 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::history_cell::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 97e4436ef..93dc40e19 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 24c46e41d..dc148f005 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/status/card.rs b/codex-rs/tui/src/status/card.rs index 918ddc177..53c1075c7 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -3,9 +3,12 @@ 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; @@ -14,9 +17,6 @@ use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; 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; @@ -247,6 +247,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()), @@ -257,10 +259,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 { @@ -283,18 +282,13 @@ impl StatusHistoryCell { .find(|(k, _)| *k == "approval") .map(|(_, v)| v.clone()) .unwrap_or_else(|| "".to_string()); - let permission_profile = config.permissions.permission_profile(); let active_permission_profile = config.permissions.active_permission_profile(); let sandbox = status_permission_summary(&permission_profile, config.cwd.as_path()); - let approval = status_approval_label( - config.permissions.approval_policy.value(), - config.approvals_reviewer, - &approval, - ); + let approval = status_approval_label(approval_policy, config.approvals_reviewer, &approval); let permissions = status_permissions_label( active_permission_profile.as_ref(), &permission_profile, - config.permissions.approval_policy.value(), + approval_policy, &sandbox, &approval, ); diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 559da21e8..3be813beb 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 a44e7da68..9dc923db8 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -8,29 +8,73 @@ 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::ApprovalsReviewer; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; -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 codex_protocol::permissions::NetworkSandboxPolicy; 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()) @@ -40,11 +84,8 @@ async fn test_config(temp_home: &TempDir) -> Config { config.approvals_reviewer = ApprovalsReviewer::User; 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 @@ -171,13 +212,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, @@ -224,16 +265,13 @@ async fn status_permissions_non_default_workspace_write_uses_workspace_label() { 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"); @@ -250,7 +288,7 @@ async fn status_permissions_named_read_only_profile_shows_builtin_label() { config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); config .permissions @@ -273,7 +311,7 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots() config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); let extra_root = test_path_buf("/workspace/extra").abs(); let file_system_policy = PermissionProfile::read_only() @@ -309,7 +347,7 @@ async fn status_permissions_named_workspace_profile_shows_builtin_label() { config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); config .permissions @@ -333,7 +371,7 @@ async fn status_permissions_workspace_auto_review_shows_reviewer_label() { config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); config .permissions @@ -356,7 +394,7 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() { config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); let extra_root = test_path_buf("/workspace/extra").abs(); config @@ -391,7 +429,7 @@ async fn status_permissions_broadened_workspace_profile_shows_builtin_label() { config .permissions .approval_policy - .set(AskForApproval::OnRequest) + .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); config .permissions @@ -532,14 +570,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!( @@ -555,14 +596,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!( @@ -650,8 +694,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, @@ -956,8 +1000,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, @@ -1116,13 +1160,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, @@ -1183,13 +1227,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 { @@ -1369,13 +1413,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, @@ -1436,13 +1480,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/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 471c0b4bd..94aec5d7f 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -148,11 +148,6 @@ impl StatusIndicatorWidget { self.show_interrupt_hint = visible; } - #[cfg(test)] - pub(crate) fn interrupt_hint_visible(&self) -> bool { - self.show_interrupt_hint - } - pub(crate) fn pause_timer(&mut self) { self.pause_timer_at(Instant::now()); } diff --git a/codex-rs/tui/src/test_support.rs b/codex-rs/tui/src/test_support.rs index 27975c354..53fd8adf6 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/voice.rs b/codex-rs/tui/src/voice.rs index 229d0a8db..ae3a1a8ad 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()); }