From a267ac325746c26964fd748f96a09640ad19f44d Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 9 Jul 2026 09:39:28 +0300 Subject: [PATCH 1/3] Repair TUI thread routing stack --- codex-rs/core/src/usage_profile_health.rs | 203 +++- .../tui/src/app/app_server_event_targets.rs | 13 + codex-rs/tui/src/app/app_server_events.rs | 38 +- codex-rs/tui/src/app/event_dispatch.rs | 24 + codex-rs/tui/src/app/loaded_threads.rs | 18 +- codex-rs/tui/src/app/session_lifecycle.rs | 20 +- codex-rs/tui/src/app/tests.rs | 1001 +++++++++++++---- codex-rs/tui/src/app/thread_routing.rs | 122 +- .../tui/src/app/thread_workflow_actions.rs | 31 + codex-rs/tui/src/app_event.rs | 30 + codex-rs/tui/src/chatwidget.rs | 5 + .../tui/src/chatwidget/auth_profile_popups.rs | 16 +- codex-rs/tui/src/chatwidget/changelog.rs | 409 ++++++- codex-rs/tui/src/chatwidget/constructor.rs | 1 + codex-rs/tui/src/chatwidget/rate_limits.rs | 62 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 10 +- ...ts__changelog_browser_release_bullets.snap | 27 + ...og__tests__changelog_browser_versions.snap | 17 + ...slash_changelog_release_notes_preview.snap | 15 - ...__tests__workflow_manager_active_runs.snap | 11 + ...orkflow_manager_completed_run_actions.snap | 15 + ...widget__tests__workflow_manager_empty.snap | 11 + ...widget__tests__workflow_manager_error.snap | 11 + ...dget__tests__workflow_manager_loading.snap | 10 + codex-rs/tui/src/chatwidget/tests.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 37 + .../src/chatwidget/tests/slash_commands.rs | 89 +- .../src/chatwidget/tests/status_and_layout.rs | 32 + .../src/chatwidget/tests/workflow_manager.rs | 268 +++++ .../src/chatwidget/usage_profile_broker.rs | 30 +- .../tui/src/chatwidget/workflow_display.rs | 10 +- .../tui/src/chatwidget/workflow_manager.rs | 692 ++++++++++++ codex-rs/tui/src/slash_command.rs | 5 + 33 files changed, 2959 insertions(+), 325 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__changelog__tests__changelog_browser_release_bullets.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__changelog__tests__changelog_browser_versions.snap delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_changelog_release_notes_preview.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_active_runs.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_completed_run_actions.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_empty.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_error.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_loading.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/workflow_manager.rs create mode 100644 codex-rs/tui/src/chatwidget/workflow_manager.rs diff --git a/codex-rs/core/src/usage_profile_health.rs b/codex-rs/core/src/usage_profile_health.rs index c446a6f96..ce110008f 100644 --- a/codex-rs/core/src/usage_profile_health.rs +++ b/codex-rs/core/src/usage_profile_health.rs @@ -151,6 +151,32 @@ pub fn exhausted_auto_switch_window_for_snapshot( .find_map(exhausted_auto_switch_window_for_limit) } +/// Earliest future reset timestamp (unix seconds) among exhausted (`used_percent >= 100`) +/// codex windows in `snapshot`. +/// +/// Returns `None` when the limit is not codex, nothing is exhausted, or every exhausted +/// window's reset is missing or already in the past. Callers use this to suppress usage +/// heartbeats for a profile that is already capped until it resets, instead of re-polling +/// the usage endpoint every heartbeat interval (which just hammers a limit we already know +/// about). When the reset is unknown, `None` lets the caller fall back to the normal +/// interval/backoff so heartbeats are never permanently blocked. +pub fn earliest_exhausted_reset_at( + snapshot: &UsageProfileRateLimitSnapshot<'_>, + now_unix_secs: i64, +) -> Option { + if !is_codex_limit(snapshot) { + return None; + } + + [snapshot.secondary, snapshot.primary] + .into_iter() + .flatten() + .filter(|window| window.used_percent >= 100.0) + .filter_map(|window| window.resets_at) + .filter(|reset_at| *reset_at > now_unix_secs) + .min() +} + pub fn usage_health_for_snapshots( snapshots: &[UsageProfileRateLimitSnapshot<'_>], config: &AuthProfileAutoSwitchConfig, @@ -224,6 +250,12 @@ pub fn choose_profile_for_auto_switch( let mut retry_at = None; match config.strategy { AuthProfileAutoSwitchStrategy::Ordered => { + // Respect the configured order, but never optimistically switch to an + // Unknown profile when a later candidate is known to be healthy: a + // known-healthy profile is always a safer switch target than one whose + // usage we have not confirmed. + let mut first_healthy = None; + let mut first_unknown = None; for candidate in candidates { match health_by_profile .get(candidate) @@ -231,22 +263,32 @@ pub fn choose_profile_for_auto_switch( .unwrap_or(UsageProfileHealth::Unknown) { UsageProfileHealth::Healthy(_) => { - return UsageProfileSelection::selected( - candidate.clone(), - UsageProfileSelectionReason::SelectedHealthyProfile, - ); + if first_healthy.is_none() { + first_healthy = Some(candidate.as_str()); + } } UsageProfileHealth::Unknown => { - return UsageProfileSelection::selected( - candidate.clone(), - UsageProfileSelectionReason::SelectedUnknownProfile, - ); + if first_unknown.is_none() { + first_unknown = Some(candidate.as_str()); + } } UsageProfileHealth::Exhausted { retry_at: profile_retry_at, } => merge_retry_at(&mut retry_at, profile_retry_at), } } + if let Some(profile) = first_healthy { + return UsageProfileSelection::selected( + profile.to_string(), + UsageProfileSelectionReason::SelectedHealthyProfile, + ); + } + if let Some(profile) = first_unknown { + return UsageProfileSelection::selected( + profile.to_string(), + UsageProfileSelectionReason::SelectedUnknownProfile, + ); + } } AuthProfileAutoSwitchStrategy::HighestAvailable => { let mut best: Option<(&str, UsageProfileScore)> = None; @@ -644,6 +686,151 @@ mod tests { ); } + fn ordered_config() -> AuthProfileAutoSwitchConfig { + AuthProfileAutoSwitchConfig { + enabled: true, + strategy: AuthProfileAutoSwitchStrategy::Ordered, + ..Default::default() + } + } + + #[test] + fn ordered_prefers_healthy_over_earlier_unknown() { + let health_by_profile = BTreeMap::from([ + ("first".to_string(), UsageProfileHealth::Unknown), + ( + "second".to_string(), + UsageProfileHealth::Healthy(UsageProfileScore { + trigger_remaining_percent: 40.0, + limiting_remaining_percent: 40.0, + }), + ), + ]); + + assert_eq!( + UsageProfileSelection { + selected_profile: Some("second".to_string()), + retry_at: None, + reason: UsageProfileSelectionReason::SelectedHealthyProfile, + }, + choose_profile_for_auto_switch( + &ordered_config(), + &["first".to_string(), "second".to_string()], + &health_by_profile, + ) + ); + } + + #[test] + fn ordered_keeps_first_healthy_in_configured_order() { + let health_by_profile = BTreeMap::from([ + ( + "first".to_string(), + UsageProfileHealth::Healthy(UsageProfileScore { + trigger_remaining_percent: 20.0, + limiting_remaining_percent: 20.0, + }), + ), + ( + "second".to_string(), + UsageProfileHealth::Healthy(UsageProfileScore { + trigger_remaining_percent: 90.0, + limiting_remaining_percent: 90.0, + }), + ), + ]); + + assert_eq!( + UsageProfileSelection { + selected_profile: Some("first".to_string()), + retry_at: None, + reason: UsageProfileSelectionReason::SelectedHealthyProfile, + }, + choose_profile_for_auto_switch( + &ordered_config(), + &["first".to_string(), "second".to_string()], + &health_by_profile, + ) + ); + } + + #[test] + fn ordered_falls_back_to_first_unknown_when_no_healthy() { + let health_by_profile = BTreeMap::from([( + "first".to_string(), + UsageProfileHealth::Exhausted { + retry_at: Some(500), + }, + )]); + + assert_eq!( + UsageProfileSelection { + selected_profile: Some("second".to_string()), + retry_at: None, + reason: UsageProfileSelectionReason::SelectedUnknownProfile, + }, + choose_profile_for_auto_switch( + &ordered_config(), + &["first".to_string(), "second".to_string()], + &health_by_profile, + ) + ); + } + + #[test] + fn earliest_exhausted_reset_at_returns_future_reset() { + assert_eq!( + Some(1_000), + earliest_exhausted_reset_at( + &snapshot( + Some(window(100.0, MINUTES_PER_5_HOURS, Some(1_000))), + Some(window(100.0, MINUTES_PER_WEEK, Some(2_000))), + ), + 500, + ) + ); + } + + #[test] + fn earliest_exhausted_reset_at_ignores_unexhausted_and_past_resets() { + // Not exhausted -> None. + assert_eq!( + None, + earliest_exhausted_reset_at( + &snapshot(Some(window(80.0, MINUTES_PER_5_HOURS, Some(1_000))), None), + 500, + ) + ); + // Exhausted but reset already elapsed -> None (fall back to normal interval). + assert_eq!( + None, + earliest_exhausted_reset_at( + &snapshot(Some(window(100.0, MINUTES_PER_5_HOURS, Some(400))), None), + 500, + ) + ); + // Exhausted but reset unknown -> None. + assert_eq!( + None, + earliest_exhausted_reset_at( + &snapshot(Some(window(100.0, MINUTES_PER_5_HOURS, None)), None), + 500, + ) + ); + } + + #[test] + fn earliest_exhausted_reset_at_ignores_non_codex_limits() { + let snapshot = UsageProfileRateLimitSnapshot { + limit_id: Some("not-codex"), + limit_name: None, + primary: Some(window(100.0, MINUTES_PER_5_HOURS, Some(1_000))), + secondary: None, + }; + + assert_eq!(None, earliest_exhausted_reset_at(&snapshot, 500)); + } + #[test] fn exhausted_candidates_merge_earliest_retry_timestamp() { let health_by_profile = BTreeMap::from([ diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index ef0f1dfaa..f0c5d9583 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -39,6 +39,19 @@ pub(super) enum ServerNotificationThreadTarget { Global, } +impl ServerNotificationThreadTarget { + /// The thread this notification targets, if it is addressed to a specific + /// thread. Non-thread targets (app-scoped, global, invalid) return `None`. + pub(super) fn thread_id(&self) -> Option { + match self { + ServerNotificationThreadTarget::Thread(thread_id) => Some(*thread_id), + ServerNotificationThreadTarget::InvalidThreadId(_) + | ServerNotificationThreadTarget::AppScoped + | ServerNotificationThreadTarget::Global => None, + } + } +} + pub(super) fn server_notification_thread_target( notification: &ServerNotification, ) -> ServerNotificationThreadTarget { diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index cb5189264..ae41933b1 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -129,10 +129,21 @@ impl App { self.note_session_recap_turn_completed(thread_id, ¬ification.turn); } - let result = if self.primary_thread_id == Some(thread_id) - || self.primary_thread_id.is_none() - { - self.enqueue_primary_thread_notification(notification).await + let result = if self.primary_thread_id == Some(thread_id) { + self.enqueue_thread_notification(thread_id, notification) + .await + } else if self.primary_thread_id.is_none() { + // Startup pre-primary window: the primary thread has not + // finished starting yet. Only buffer as a pending primary + // event when the target thread is not already tracked; a + // known loaded/scheduled thread routes to its own channel + // so its events never leak into the primary buffer. + if self.is_tracked_thread(thread_id) { + self.enqueue_thread_notification(thread_id, notification) + .await + } else { + self.enqueue_primary_thread_notification(notification).await + } } else { self.enqueue_thread_notification(thread_id, notification) .await @@ -204,12 +215,19 @@ impl App { 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 - }; + let result = if self.primary_thread_id == Some(thread_id) { + self.enqueue_thread_request(thread_id, request).await + } else if self.primary_thread_id.is_none() { + // Startup pre-primary window: route requests to their addressed + // thread channel immediately. If this later becomes the primary + // thread, startup attaches to the existing channel and preserves + // the buffered request. If it is some other top-level thread, the + // request must not be parked in the pending-primary queue and then + // dropped after already being registered for resolution. + self.enqueue_thread_request(thread_id, request).await + } else { + self.enqueue_thread_request(thread_id, request).await + }; if let Err(err) = result { tracing::warn!("failed to enqueue app-server request: {err}"); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ad1a14585..617b818eb 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -920,6 +920,12 @@ impl App { AppEvent::ReloadMcpServers => { self.reload_mcp_servers(app_server); } + AppEvent::OpenChangelogBrowser => { + self.chat_widget.open_changelog_browser(); + } + AppEvent::OpenChangelogRelease { version } => { + self.chat_widget.open_changelog_release(version); + } AppEvent::OpenBackgroundTerminalManager => { self.chat_widget.open_background_terminal_manager(); } @@ -1027,6 +1033,24 @@ impl App { self.manage_thread_workflow(app_server, thread_id, action) .await; } + AppEvent::OpenThreadWorkflowManager { thread_id } => { + self.open_thread_workflow_manager(app_server, thread_id) + .await; + } + AppEvent::OpenThreadWorkflowDraftPrompt => { + self.chat_widget.show_thread_workflow_draft_prompt(); + } + AppEvent::OpenThreadWorkflowActions { + thread_id, + workflow, + } => { + self.chat_widget + .show_thread_workflow_actions(thread_id, workflow); + } + AppEvent::OpenThreadWorkflowRunActions { thread_id, run } => { + self.chat_widget + .show_thread_workflow_run_actions(thread_id, run); + } AppEvent::OpenThreadGoalPlanDetail { thread_id, plan } => { self.chat_widget.show_goal_plan_detail(thread_id, plan); } diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index 106b0d758..e4705b1ef 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::Thread; use codex_protocol::ThreadId; +use codex_protocol::protocol::SubAgentSource; use std::collections::HashMap; use std::collections::HashSet; @@ -93,16 +94,17 @@ pub(crate) fn find_loaded_subagent_threads_for_primary( loaded_threads } -fn thread_spawn_parent_thread_id( +pub(crate) 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() + let codex_app_server_protocol::SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + .. + }) = source + else { + return None; + }; + Some(*parent_thread_id) } #[cfg(test)] diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 395bd8438..47414dc3d 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -113,12 +113,19 @@ impl App { } pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) { - let mut thread_ids = self.agent_navigation.tracked_thread_ids(); - for thread_id in self.thread_event_channels.keys().copied() { - if !thread_ids.contains(&thread_id) { - thread_ids.push(thread_id); - } + if let Some(primary_thread_id) = self.primary_thread_id + && self.agent_navigation.get(&primary_thread_id).is_none() + { + self.upsert_agent_picker_thread( + primary_thread_id, + /*agent_nickname*/ None, + /*agent_role*/ None, + /*is_closed*/ false, + ); } + // Only refresh known picker entries. Schedule, monitor, and other top-level thread + // notifications can create replay channels, but a buffered channel is not picker lineage. + let thread_ids = self.agent_navigation.tracked_thread_ids(); for thread_id in thread_ids { if self.side_threads.contains_key(&thread_id) { continue; @@ -204,6 +211,9 @@ impl App { /// Closing a thread is not the same as removing it: users can still inspect finished agent /// transcripts, and the stable next/previous traversal order should not collapse around them. pub(super) fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) { + if self.agent_navigation.get(&thread_id).is_none() { + return; + } self.agent_navigation.mark_closed(thread_id); self.sync_active_agent_label(); } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 746ce1c61..7e17e27a4 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -73,10 +73,15 @@ use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSchedule; use codex_app_server_protocol::ThreadScheduleIntervalUnit; use codex_app_server_protocol::ThreadSchedulePromptSource; +use codex_app_server_protocol::ThreadScheduleRun; +use codex_app_server_protocol::ThreadScheduleRunStatus; +use codex_app_server_protocol::ThreadScheduleRunUpdatedNotification; use codex_app_server_protocol::ThreadScheduleSpec; use codex_app_server_protocol::ThreadScheduleStatus; +use codex_app_server_protocol::ThreadScheduleUpdatedNotification; use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadStartedNotification; @@ -108,6 +113,7 @@ use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::user_input::TextElement; use codex_utils_absolute_path::AbsolutePathBuf; @@ -134,6 +140,17 @@ fn test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") } +fn large_stack_test_runtime() -> Result { + const WORKER_THREADS: usize = 1; + const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; + + Ok(tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .thread_stack_size(TEST_STACK_SIZE_BYTES) + .enable_all() + .build()?) +} + async fn next_thread_settings_updated( app_server: &mut AppServerSession, thread_id: ThreadId, @@ -323,6 +340,190 @@ async fn resolved_buffered_approval_does_not_become_actionable_after_drain() -> Ok(()) } +#[tokio::test] +async fn enqueue_primary_thread_session_drops_unrelated_pre_primary_notifications() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let primary_thread_id = ThreadId::new(); + let unrelated_thread_id = ThreadId::new(); + + // Pre-primary startup window: no primary thread is set yet, so events are + // buffered as pending primary events. + assert!(app.primary_thread_id.is_none()); + app.enqueue_primary_thread_notification(agent_message_delta_notification( + unrelated_thread_id, + "turn-x", + "item-x", + "unrelated", + )) + .await?; + app.enqueue_primary_thread_notification(agent_message_delta_notification( + primary_thread_id, + "turn-1", + "item-1", + "primary", + )) + .await?; + + app.enqueue_primary_thread_session( + test_thread_session(primary_thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + while app_event_rx.try_recv().is_ok() {} + + // The unrelated thread must not leak a channel out of the primary buffer flush. + assert!( + !app.thread_event_channels.contains_key(&unrelated_thread_id), + "unrelated thread should not receive a channel from the primary flush" + ); + + let rx = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active"); + + let event = time::timeout(Duration::from_millis(50), rx.recv()) + .await + .expect("timed out waiting for buffered primary notification") + .expect("channel closed unexpectedly"); + assert!( + matches!( + &event, + ThreadBufferedEvent::Notification(ServerNotification::AgentMessageDelta(delta)) + if delta.thread_id == primary_thread_id.to_string() && delta.delta == "primary" + ), + "expected the primary thread's buffered notification to replay first, got {event:?}" + ); + + // The unrelated-thread notification must have been dropped, not replayed onto + // the primary thread's stream. + let extra = time::timeout(Duration::from_millis(50), rx.recv()).await; + assert!( + extra.is_err(), + "unrelated pre-primary notification should be dropped, not replayed onto the primary" + ); + + Ok(()) +} + +#[tokio::test] +async fn enqueue_primary_thread_session_routes_known_thread_pre_primary_notifications() -> Result<()> +{ + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let primary_thread_id = ThreadId::new(); + let known_thread_id = ThreadId::new(); + + // A previously loaded/scheduled thread is already tracked before the primary + // thread finishes starting. + app.thread_event_channels.insert( + known_thread_id, + ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), + ); + // Mark the known thread active so routed events are delivered on its channel + // receiver rather than parked in its store buffer. + app.set_thread_active(known_thread_id, /*active*/ true) + .await; + + assert!(app.primary_thread_id.is_none()); + app.enqueue_primary_thread_notification(agent_message_delta_notification( + known_thread_id, + "turn-k", + "item-k", + "known", + )) + .await?; + + app.enqueue_primary_thread_session( + test_thread_session(primary_thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + while app_event_rx.try_recv().is_ok() {} + + // The buffered event for the already-tracked thread is delivered on that + // thread's own channel, not on the primary thread's stream. + let mut known_rx = app + .thread_event_channels + .get_mut(&known_thread_id) + .expect("known thread channel should still exist") + .receiver + .take() + .expect("known thread receiver should be available"); + let known_event = time::timeout(Duration::from_millis(50), known_rx.recv()) + .await + .expect("timed out waiting for known-thread notification") + .expect("known thread channel closed unexpectedly"); + assert!( + matches!( + &known_event, + ThreadBufferedEvent::Notification(ServerNotification::AgentMessageDelta(delta)) + if delta.thread_id == known_thread_id.to_string() && delta.delta == "known" + ), + "expected known-thread notification to route to its own channel, got {known_event:?}" + ); + + // Nothing from the known thread should have leaked onto the primary stream. + let rx = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active"); + let leaked = time::timeout(Duration::from_millis(50), rx.recv()).await; + assert!( + leaked.is_err(), + "known-thread notification must not be replayed onto the primary stream" + ); + + Ok(()) +} + +#[tokio::test] +async fn pre_primary_untracked_request_stays_resolvable_on_addressed_thread() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let primary_thread_id = ThreadId::new(); + let request_thread_id = ThreadId::new(); + let request = exec_approval_request( + request_thread_id, + "turn-request", + "call-request", + /*approval_id*/ None, + ); + + assert!(app.primary_thread_id.is_none()); + assert_eq!( + app.pending_app_server_requests + .note_server_request(&request), + None + ); + app.enqueue_thread_request(request_thread_id, request) + .await?; + + app.enqueue_primary_thread_session( + test_thread_session(primary_thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + + assert!( + app.thread_event_channels.contains_key(&request_thread_id), + "request thread should keep its own channel instead of being dropped from primary startup" + ); + let resolution = app + .pending_app_server_requests + .take_resolution(&Op::ExecApproval { + id: "call-request".to_string(), + turn_id: None, + decision: codex_app_server_protocol::CommandExecutionApprovalDecision::Accept, + }) + .map_err(|err| color_eyre::eyre::eyre!(err))? + .expect("approval request should remain resolvable"); + assert_eq!( + resolution.result, + serde_json::json!({ "decision": "accept" }) + ); + + Ok(()) +} + #[tokio::test] async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -1158,7 +1359,7 @@ async fn token_usage_update_refreshes_status_line_with_runtime_context_window() } #[tokio::test] -async fn collab_receiver_notification_caches_thread_without_app_server_read() { +async fn collab_receiver_notification_does_not_create_picker_row_without_verified_lineage() { let mut app = make_test_app().await; let receiver_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id"); @@ -1182,15 +1383,8 @@ async fn collab_receiver_notification_caches_thread_without_app_server_read() { }), )); - assert_eq!( - app.agent_navigation.get(&receiver_thread_id), - Some(&AgentPickerThreadEntry { - agent_nickname: None, - agent_role: None, - thread_name: None, - is_closed: false, - }) - ); + assert_eq!(app.agent_navigation.get(&receiver_thread_id), None); + assert!(!app.thread_event_channels.contains_key(&receiver_thread_id)); } #[tokio::test] @@ -1227,124 +1421,124 @@ async fn collab_receiver_notification_does_not_cache_not_found_thread() { assert_eq!(app.agent_navigation.get(&receiver_thread_id), None); } -#[tokio::test] -async fn open_agent_picker_keeps_missing_threads_for_replay() -> 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 thread_id = ThreadId::new(); - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); +#[test] +fn open_agent_picker_does_not_promote_unverified_replay_channels() -> Result<()> { + large_stack_test_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 thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); - Box::pin(app.open_agent_picker(&mut app_server)).await; + Box::pin(app.open_agent_picker(&mut app_server)).await; - assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); - assert_eq!( - app.agent_navigation.get(&thread_id), - Some(&AgentPickerThreadEntry { - agent_nickname: None, - agent_role: None, - thread_name: None, - is_closed: true, - }) - ); - assert_eq!(app.agent_navigation.ordered_thread_ids(), vec![thread_id]); - Ok(()) + assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); + assert_eq!(app.agent_navigation.get(&thread_id), None); + assert!(app.agent_navigation.ordered_thread_ids().is_empty()); + Ok(()) + }) } -#[tokio::test] -async fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> 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 thread_id = ThreadId::new(); - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); - app.agent_navigation.upsert( - thread_id, - Some("Robie".to_string()), - Some("explorer".to_string()), - /*is_closed*/ true, - ); +#[test] +fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Result<()> { + large_stack_test_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 thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); + app.agent_navigation.upsert( + thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + /*is_closed*/ true, + ); - Box::pin(app.open_agent_picker(&mut app_server)).await; + Box::pin(app.open_agent_picker(&mut app_server)).await; - assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); - assert_eq!( - app.agent_navigation.get(&thread_id), - Some(&AgentPickerThreadEntry { - agent_nickname: Some("Robie".to_string()), - agent_role: Some("explorer".to_string()), - thread_name: None, - is_closed: true, - }) - ); - Ok(()) + assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); + assert_eq!( + app.agent_navigation.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + thread_name: None, + is_closed: true, + }) + ); + Ok(()) + }) } -#[tokio::test] -async fn open_agent_picker_prunes_terminal_metadata_only_threads() -> 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 thread_id = ThreadId::new(); - app.agent_navigation.upsert( - thread_id, - Some("Ghost".to_string()), - Some("worker".to_string()), - /*is_closed*/ false, - ); +#[test] +fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()> { + large_stack_test_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 thread_id = ThreadId::new(); + app.agent_navigation.upsert( + thread_id, + Some("Ghost".to_string()), + Some("worker".to_string()), + /*is_closed*/ false, + ); - Box::pin(app.open_agent_picker(&mut app_server)).await; + Box::pin(app.open_agent_picker(&mut app_server)).await; - assert_eq!(app.agent_navigation.get(&thread_id), None); - assert!(app.agent_navigation.is_empty()); - Ok(()) + assert_eq!(app.agent_navigation.get(&thread_id), None); + assert!(app.agent_navigation.is_empty()); + Ok(()) + }) } -#[tokio::test] -async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> { - let mut app = 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 thread_id = ThreadId::new(); - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); - app.agent_navigation.upsert( - thread_id, - Some("Robie".to_string()), - Some("explorer".to_string()), - /*is_closed*/ false, - ); +#[test] +fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> { + large_stack_test_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 thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); + app.agent_navigation.upsert( + thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + /*is_closed*/ false, + ); - Box::pin(app.open_agent_picker(&mut app_server)).await; + Box::pin(app.open_agent_picker(&mut app_server)).await; - assert_eq!( - app.agent_navigation.get(&thread_id), - Some(&AgentPickerThreadEntry { - agent_nickname: Some("Robie".to_string()), - agent_role: Some("explorer".to_string()), - thread_name: None, - is_closed: true, - }) - ); - Ok(()) + assert_eq!( + app.agent_navigation.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + thread_name: None, + is_closed: true, + }) + ); + Ok(()) + }) } #[test] -fn open_agent_picker_marks_loaded_threads_open() -> Result<()> { +fn open_agent_picker_marks_primary_loaded_thread_open() -> Result<()> { const WORKER_THREADS: usize = 1; const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; @@ -1365,6 +1559,8 @@ fn open_agent_picker_marks_loaded_threads_open() -> Result<()> { .start_thread(app.chat_widget.config_ref()) .await?; let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); app.thread_event_channels .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); @@ -1517,36 +1713,38 @@ async fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_thread Ok(()) } -#[tokio::test] -async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> { - let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).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 _ = app.config.features.disable(Feature::Collab); +#[test] +fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> { + large_stack_test_runtime()?.block_on(async { + let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).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 _ = app.config.features.disable(Feature::Collab); - Box::pin(app.open_agent_picker(&mut app_server)).await; - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + Box::pin(app.open_agent_picker(&mut app_server)).await; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!( - app_event_rx.try_recv(), - Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)] - ); - let cell = match app_event_rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => cell, - other => panic!("expected InsertHistoryCell event, got {other:?}"), - }; - let rendered = cell - .display_lines(/*width*/ 120) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(rendered.contains("Subagents will be enabled in the next session.")); - Ok(()) + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)] + ); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = cell + .display_lines(/*width*/ 120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(rendered.contains("Subagents will be enabled in the next session.")); + Ok(()) + }) } #[test] @@ -1801,6 +1999,8 @@ default_permissions = "locked-down" "#, )?; app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.selected_auth_profile = None; + app.chat_widget.set_auth_profile(None); app.loader_overrides.user_config_path = Some(selected_config.abs()); app.harness_overrides.sandbox_mode = Some(SandboxMode::WorkspaceWrite); app.harness_overrides.permission_profile = Some(PermissionProfile::workspace_write()); @@ -2216,27 +2416,35 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit Ok(()) } -#[tokio::test] -async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()> { - let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).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 thread_id = ThreadId::new(); - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); +#[test] +fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()> { + large_stack_test_runtime()?.block_on(async { + let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).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 thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); + app.agent_navigation.upsert( + thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + /*is_closed*/ false, + ); - Box::pin(app.open_agent_picker(&mut app_server)).await; - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + Box::pin(app.open_agent_picker(&mut app_server)).await; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!( - app_event_rx.try_recv(), - Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id - ); - Ok(()) + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id + ); + Ok(()) + }) } #[tokio::test] @@ -2881,7 +3089,8 @@ async fn inactive_thread_approval_badge_clears_after_turn_completion_notificatio } #[tokio::test] -async fn inactive_thread_started_notification_initializes_replay_session() -> Result<()> { +async fn inactive_thread_started_notification_with_unknown_source_initializes_replay_session_without_picker_row() +-> Result<()> { let mut app = make_test_app().await; let temp_dir = tempdir()?; let main_thread_id = @@ -2974,6 +3183,43 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re vec![test_path_buf("/tmp/agent").abs(), shared_root] ); assert_eq!(session.rollout_path, Some(rollout_path)); + assert_eq!(app.agent_navigation.get(&agent_thread_id), None); + + Ok(()) +} + +#[tokio::test] +async fn inactive_thread_started_notification_registers_direct_thread_spawn_child() -> Result<()> { + let mut app = make_test_app().await; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000111").expect("valid thread"); + let agent_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000222").expect("valid thread"); + let primary_cwd = test_path_buf("/tmp/main").abs(); + let primary_session = test_thread_session(main_thread_id, primary_cwd.to_path_buf()); + + app.primary_thread_id = Some(main_thread_id); + app.active_thread_id = Some(main_thread_id); + app.primary_session_configured = Some(primary_session); + app.agent_navigation.upsert( + main_thread_id, + /*agent_nickname*/ None, + /*agent_role*/ None, + /*is_closed*/ false, + ); + + app.enqueue_thread_notification( + agent_thread_id, + thread_started_notification( + agent_thread_id, + thread_spawn_source(main_thread_id, /*depth*/ 1, "Robie", "explorer"), + Some("Robie"), + Some("explorer"), + Some("agent thread"), + ), + ) + .await?; + assert_eq!( app.agent_navigation.get(&agent_thread_id), Some(&AgentPickerThreadEntry { @@ -2987,6 +3233,101 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re Ok(()) } +#[tokio::test] +async fn inactive_thread_started_notification_registers_verified_thread_spawn_grandchild() +-> Result<()> { + let mut app = make_test_app().await; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000311").expect("valid thread"); + let child_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000322").expect("valid thread"); + let grandchild_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000333").expect("valid thread"); + let primary_cwd = test_path_buf("/tmp/main").abs(); + let primary_session = test_thread_session(main_thread_id, primary_cwd.to_path_buf()); + + app.primary_thread_id = Some(main_thread_id); + app.active_thread_id = Some(main_thread_id); + app.primary_session_configured = Some(primary_session); + app.agent_navigation.upsert( + main_thread_id, + /*agent_nickname*/ None, + /*agent_role*/ None, + /*is_closed*/ false, + ); + app.agent_navigation.upsert( + child_thread_id, + Some("Scout".to_string()), + Some("worker".to_string()), + /*is_closed*/ false, + ); + + app.enqueue_thread_notification( + grandchild_thread_id, + thread_started_notification( + grandchild_thread_id, + thread_spawn_source(child_thread_id, /*depth*/ 2, "Atlas", "researcher"), + Some("Atlas"), + Some("researcher"), + Some("nested agent"), + ), + ) + .await?; + + assert_eq!( + app.agent_navigation.get(&grandchild_thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("Atlas".to_string()), + agent_role: Some("researcher".to_string()), + thread_name: Some("nested agent".to_string()), + is_closed: false, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn inactive_thread_started_notification_excludes_unrelated_thread_spawn_child() -> Result<()> +{ + let mut app = make_test_app().await; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000411").expect("valid thread"); + let unrelated_parent_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000422").expect("valid thread"); + let agent_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000433").expect("valid thread"); + let primary_cwd = test_path_buf("/tmp/main").abs(); + let primary_session = test_thread_session(main_thread_id, primary_cwd.to_path_buf()); + + app.primary_thread_id = Some(main_thread_id); + app.active_thread_id = Some(main_thread_id); + app.primary_session_configured = Some(primary_session); + app.agent_navigation.upsert( + main_thread_id, + /*agent_nickname*/ None, + /*agent_role*/ None, + /*is_closed*/ false, + ); + + app.enqueue_thread_notification( + agent_thread_id, + thread_started_notification( + agent_thread_id, + thread_spawn_source(unrelated_parent_id, /*depth*/ 1, "Other", "explorer"), + Some("Other"), + Some("explorer"), + Some("unrelated agent"), + ), + ) + .await?; + + assert_eq!(app.agent_navigation.get(&agent_thread_id), None); + assert!(app.thread_event_channels.contains_key(&agent_thread_id)); + + Ok(()) +} + #[tokio::test] async fn inactive_thread_started_notification_preserves_primary_model_when_path_missing() -> Result<()> { @@ -3225,46 +3566,177 @@ async fn agent_picker_selection_items_include_new_agent_snapshot() { ); } -#[tokio::test] -async fn open_agent_picker_new_agent_row_emits_create_event() -> Result<()> { - let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; - let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( - app.chat_widget.config_ref(), - )) - .await - .expect("embedded app server"); +#[test] +fn scheduled_top_level_thread_does_not_leak_into_agent_navigation() -> 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; - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .thread_stack_size(TEST_STACK_SIZE_BYTES) + .enable_all() + .build()?; - assert_matches!(app_event_rx.try_recv(), Ok(AppEvent::CreateAgentThread)); - 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?; + let primary = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let scheduled = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let primary_thread_id = primary.session.thread_id; + let scheduled_thread_id = scheduled.session.thread_id; + let scheduled_thread_id_text = scheduled_thread_id.to_string(); + + app.primary_thread_id = Some(primary_thread_id); + app.active_thread_id = Some(primary_thread_id); + app.primary_session_configured = Some(primary.session.clone()); + app.thread_event_channels.insert( + primary_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + primary.session, + primary.turns, + ), + ); + app.agent_navigation.upsert( + primary_thread_id, + /*agent_nickname*/ None, + /*agent_role*/ None, + /*is_closed*/ false, + ); + + app.enqueue_thread_notification( + scheduled_thread_id, + schedule_updated_notification(scheduled_thread_id, "schedule-1"), + ) + .await?; + app.enqueue_thread_notification( + scheduled_thread_id, + schedule_run_updated_notification(scheduled_thread_id, "schedule-1", "run-1"), + ) + .await?; + + assert!(app.thread_event_channels.contains_key(&scheduled_thread_id)); + + Box::pin(app.open_agent_picker(&mut app_server)).await; + let (items, _) = app.agent_picker_selection_items(/*can_create_agent*/ true); + + assert_eq!( + app.agent_navigation.ordered_thread_ids(), + vec![primary_thread_id] + ); + assert_eq!(app.agent_navigation.get(&scheduled_thread_id), None); + assert!( + !items + .iter() + .any(|item| item.description.as_deref() == Some(scheduled_thread_id_text.as_str())), + "unrelated scheduled thread must not be visible in /agent" + ); + assert_eq!( + Box::pin( + app.adjacent_thread_id_with_backfill( + &mut app_server, + AgentNavigationDirection::Next, + ) + ) + .await, + None + ); + assert_eq!( + Box::pin(app.adjacent_thread_id_with_backfill( + &mut app_server, + AgentNavigationDirection::Previous, + )) + .await, + None + ); + + let mut snapshot = { + let channel = app + .thread_event_channels + .get(&scheduled_thread_id) + .expect("scheduled thread event channel"); + let store = channel.store.lock().await; + store.snapshot() + }; + assert_buffered_schedule_notifications(&snapshot.events, scheduled_thread_id); + + app.refresh_snapshot_turns_from_thread_read( + &mut app_server, + scheduled_thread_id, + &mut snapshot, + ) + .await; + assert_buffered_schedule_notifications(&snapshot.events, scheduled_thread_id); + + app.apply_refreshed_snapshot_thread(scheduled_thread_id, scheduled, &mut snapshot) + .await; + + assert_buffered_schedule_notifications(&snapshot.events, scheduled_thread_id); + let refreshed_events = { + let channel = app + .thread_event_channels + .get(&scheduled_thread_id) + .expect("scheduled thread event channel"); + let store = channel.store.lock().await; + store.snapshot().events + }; + assert_buffered_schedule_notifications(&refreshed_events, scheduled_thread_id); + + Ok(()) + }) } -#[tokio::test] -async fn open_agent_picker_enter_still_switches_active_existing_thread() -> Result<()> { - let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).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 thread_id = ThreadId::new(); - app.primary_thread_id = Some(thread_id); - app.active_thread_id = Some(thread_id); - app.thread_event_channels - .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); +#[test] +fn open_agent_picker_new_agent_row_emits_create_event() -> Result<()> { + large_stack_test_runtime()?.block_on(async { + let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).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.open_agent_picker(&mut app_server)).await; - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + Box::pin(app.open_agent_picker(&mut app_server)).await; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!( - app_event_rx.try_recv(), - Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id - ); - Ok(()) + assert_matches!(app_event_rx.try_recv(), Ok(AppEvent::CreateAgentThread)); + Ok(()) + }) +} + +#[test] +fn open_agent_picker_enter_still_switches_active_existing_thread() -> Result<()> { + large_stack_test_runtime()?.block_on(async { + let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).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 thread_id = ThreadId::new(); + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1)); + + Box::pin(app.open_agent_picker(&mut app_server)).await; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id + ); + Ok(()) + }) } #[tokio::test] @@ -4024,6 +4496,16 @@ async fn active_non_primary_shutdown_target_still_switches_for_other_pending_exi Ok(()) } +#[tokio::test] +async fn mark_agent_picker_thread_closed_ignores_untracked_threads() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + + app.mark_agent_picker_thread_closed(thread_id); + + assert_eq!(app.agent_navigation.get(&thread_id), None); +} + async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { let mut app = make_test_app().await; app.config.cwd = test_path_buf("/tmp/project").abs(); @@ -4505,6 +4987,8 @@ async fn auth_profile_switch_includes_saved_profile_permissions() -> Result<()> #[tokio::test] async fn failed_auth_profile_login_does_not_switch_profile() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let initial_config_profile = app.config.selected_auth_profile.clone(); + let initial_widget_profile = app.chat_widget.config_ref().selected_auth_profile.clone(); while op_rx.try_recv().is_ok() {} app.complete_auth_profile_login( @@ -4513,8 +4997,11 @@ async fn failed_auth_profile_login_does_not_switch_profile() { Some("cancelled".to_string()), ); - assert_eq!(app.config.selected_auth_profile, None); - assert_eq!(app.chat_widget.config_ref().selected_auth_profile, None); + assert_eq!(app.config.selected_auth_profile, initial_config_profile); + assert_eq!( + app.chat_widget.config_ref().selected_auth_profile, + initial_widget_profile + ); assert!(op_rx.try_recv().is_err()); } @@ -5249,6 +5736,56 @@ fn rate_limit_snapshot_for_window( } } +fn thread_spawn_source( + parent_thread_id: ThreadId, + depth: i32, + agent_nickname: &str, + agent_role: &str, +) -> SessionSource { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path: None, + agent_nickname: Some(agent_nickname.to_string()), + agent_role: Some(agent_role.to_string()), + }) +} + +fn thread_started_notification( + thread_id: ThreadId, + source: SessionSource, + agent_nickname: Option<&str>, + agent_role: Option<&str>, + name: Option<&str>, +) -> ServerNotification { + ServerNotification::ThreadStarted(ThreadStartedNotification { + thread: Thread { + id: thread_id.to_string(), + session_id: thread_id.to_string(), + forked_from_id: None, + parent_thread_id: None, + preview: "agent thread".to_string(), + ephemeral: false, + model_provider: "agent-provider".to_string(), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp/agent").abs(), + cli_version: "0.0.0".to_string(), + source, + thread_source: None, + agent_nickname: agent_nickname.map(str::to_string), + agent_role: agent_role.map(str::to_string), + git_info: None, + auth_profile: None, + auth_profile_kind: AuthProfileKind::Unknown, + name: name.map(str::to_string), + turns: Vec::new(), + }, + }) +} + fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { thread_id, @@ -5508,6 +6045,80 @@ fn turn_completed_notification( }) } +fn schedule_updated_notification(thread_id: ThreadId, schedule_id: &str) -> ServerNotification { + ServerNotification::ThreadScheduleUpdated(ThreadScheduleUpdatedNotification { + thread_id: thread_id.to_string(), + schedule: ThreadSchedule { + thread_id: thread_id.to_string(), + schedule_id: schedule_id.to_string(), + parent_schedule_id: None, + nesting_depth: 1, + prompt: "check CI".to_string(), + prompt_source: ThreadSchedulePromptSource::Inline, + schedule: ThreadScheduleSpec::Interval { + amount: 3, + unit: ThreadScheduleIntervalUnit::Minutes, + }, + timezone: "UTC".to_string(), + status: ThreadScheduleStatus::Active, + next_run_at: Some(1), + last_run_at: None, + expires_at: None, + failure_count: 0, + lease_expires_at: None, + created_at: 1, + updated_at: 1, + }, + }) +} + +fn schedule_run_updated_notification( + thread_id: ThreadId, + schedule_id: &str, + run_id: &str, +) -> ServerNotification { + ServerNotification::ThreadScheduleRunUpdated(ThreadScheduleRunUpdatedNotification { + thread_id: thread_id.to_string(), + run: ThreadScheduleRun { + thread_id: thread_id.to_string(), + schedule_id: schedule_id.to_string(), + run_id: run_id.to_string(), + status: ThreadScheduleRunStatus::Running, + lease_id: "lease-1".to_string(), + turn_id: Some("turn-1".to_string()), + error: None, + scheduled_for_at: Some(1), + started_at: 1, + completed_at: None, + }, + }) +} + +fn assert_buffered_schedule_notifications(events: &[ThreadBufferedEvent], thread_id: ThreadId) { + let thread_id_text = thread_id.to_string(); + let kinds = events + .iter() + .map(|event| match event { + ThreadBufferedEvent::Notification(ServerNotification::ThreadScheduleUpdated( + notification, + )) => { + assert_eq!(notification.thread_id, thread_id_text); + assert_eq!(notification.schedule.thread_id, thread_id_text); + "schedule" + } + ThreadBufferedEvent::Notification(ServerNotification::ThreadScheduleRunUpdated( + notification, + )) => { + assert_eq!(notification.thread_id, thread_id_text); + assert_eq!(notification.run.thread_id, thread_id_text); + "run" + } + other => panic!("expected schedule notification, saw {other:?}"), + }) + .collect::>(); + assert_eq!(kinds, vec!["schedule", "run"]); +} + fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification { ServerNotification::ThreadClosed(ThreadClosedNotification { thread_id: thread_id.to_string(), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 0f5d0a8da..d52853b2b 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -4,7 +4,10 @@ //! channels, submits thread-scoped operations through the app server, and replays buffered events //! when the visible thread changes. +use super::loaded_threads::thread_spawn_parent_thread_id; use super::*; +use crate::app::app_server_event_targets::server_notification_thread_target; +use crate::app::app_server_event_targets::server_request_thread_id; use crate::app::app_server_requests::AppServerRequestResolution; use crate::session_resume::read_session_model; use codex_app_server_protocol::ThreadExternalAgentStartStatus; @@ -1005,12 +1008,12 @@ impl App { Ok(()) } - /// Locally remembers receiver threads referenced by a collab notification. + /// Validates receiver thread ids referenced by a collab notification. /// /// This intentionally avoids app-server reads on the active-thread rendering path. During large /// fan-outs the app-server can be saturated with spawn work, and blocking here would freeze the - /// TUI event loop. Metadata from `ThreadStarted` or explicit picker refreshes still fills in - /// names and roles later; until then, rendering falls back to the thread id. + /// TUI event loop. Receiver-only notifications are not proof that a thread is a spawned + /// descendant of the current primary thread, so they must not create `/agent` picker rows. pub(super) fn cache_collab_receiver_threads_for_notification( &mut self, notification: &ServerNotification, @@ -1024,22 +1027,13 @@ impl App { continue; } - let Ok(thread_id) = ThreadId::from_string(receiver_thread_id) else { + if ThreadId::from_string(receiver_thread_id).is_err() { tracing::warn!( thread_id = receiver_thread_id, - "ignoring collab receiver with invalid thread id during local caching" + "ignoring collab receiver with invalid thread id during picker lineage validation" ); continue; - }; - - if self.agent_navigation.get(&thread_id).is_some() { - continue; } - - self.upsert_agent_picker_thread( - thread_id, /*agent_nickname*/ None, /*agent_role*/ None, - /*is_closed*/ false, - ); } } @@ -1067,15 +1061,28 @@ impl App { } session.message_history = None; session.rollout_path = rollout_path; - self.upsert_agent_picker_thread( - thread_id, - notification.thread.agent_nickname.clone(), - notification.thread.agent_role.clone(), - /*is_closed*/ false, - ); - self.agent_navigation - .set_thread_name(thread_id, notification.thread.name.clone()); - self.sync_active_agent_label(); + let should_register_picker_thread = if self.primary_thread_id == Some(thread_id) { + true + } else if let (Some(primary_thread_id), Some(parent_thread_id)) = ( + self.primary_thread_id, + thread_spawn_parent_thread_id(¬ification.thread.source), + ) { + parent_thread_id == primary_thread_id + || self.agent_navigation.get(&parent_thread_id).is_some() + } else { + false + }; + if should_register_picker_thread { + self.upsert_agent_picker_thread( + thread_id, + notification.thread.agent_nickname.clone(), + notification.thread.agent_role.clone(), + /*is_closed*/ false, + ); + self.agent_navigation + .set_thread_name(thread_id, notification.thread.name.clone()); + self.sync_active_agent_label(); + } Some(session) } @@ -1209,11 +1216,37 @@ impl App { for pending_event in pending { match pending_event { ThreadBufferedEvent::Notification(notification) => { - self.enqueue_thread_notification(thread_id, notification) - .await?; + let event_thread_id = + server_notification_thread_target(¬ification).thread_id(); + match self.resolve_pending_primary_flush_target(event_thread_id, thread_id) { + Some(target_thread_id) => { + self.enqueue_thread_notification(target_thread_id, notification) + .await?; + } + None => { + tracing::debug!( + ?event_thread_id, + %thread_id, + "dropping buffered notification for unrelated untracked thread while starting primary thread" + ); + } + } } ThreadBufferedEvent::Request(request) => { - self.enqueue_thread_request(thread_id, request).await?; + let event_thread_id = server_request_thread_id(&request); + match self.resolve_pending_primary_flush_target(event_thread_id, thread_id) { + Some(target_thread_id) => { + self.enqueue_thread_request(target_thread_id, request) + .await?; + } + None => { + tracing::debug!( + ?event_thread_id, + %thread_id, + "dropping buffered request for unrelated untracked thread while starting primary thread" + ); + } + } } ThreadBufferedEvent::HistoryEntryResponse(event) => { self.enqueue_thread_history_entry_response(thread_id, event) @@ -1230,6 +1263,42 @@ impl App { Ok(()) } + /// Whether `thread_id` is already tracked by the TUI (has an event channel + /// or is a registered side thread). Used during the startup pre-primary + /// window to distinguish known loaded/scheduled threads from the primary + /// thread that is still starting. + pub(super) fn is_tracked_thread(&self, thread_id: ThreadId) -> bool { + self.thread_event_channels.contains_key(&thread_id) + || self.side_threads.contains_key(&thread_id) + } + + /// Resolve which thread a buffered pre-primary event should flush onto once + /// the primary thread (`primary_thread_id`) has started. + /// + /// - Events that target the started primary thread flush onto the primary. + /// - Events for another already-tracked thread flush onto that thread's own + /// channel so they are not misrouted onto the primary's stream. + /// - Events for an unrelated, untracked thread return `None` and are dropped + /// by the caller; they never belonged to the primary being started. + /// - Events with no resolvable thread target flush onto the primary, matching + /// the historical behavior (such events are not buffered here in practice). + fn resolve_pending_primary_flush_target( + &self, + event_thread_id: Option, + primary_thread_id: ThreadId, + ) -> Option { + match event_thread_id { + None => Some(primary_thread_id), + Some(event_thread_id) if event_thread_id == primary_thread_id => { + Some(primary_thread_id) + } + Some(event_thread_id) if self.is_tracked_thread(event_thread_id) => { + Some(event_thread_id) + } + Some(_) => None, + } + } + pub(super) async fn enqueue_primary_thread_notification( &mut self, notification: ServerNotification, @@ -1244,6 +1313,7 @@ impl App { Ok(()) } + #[cfg_attr(not(test), allow(dead_code))] pub(super) async fn enqueue_primary_thread_request( &mut self, request: ServerRequest, diff --git a/codex-rs/tui/src/app/thread_workflow_actions.rs b/codex-rs/tui/src/app/thread_workflow_actions.rs index 8a4c72347..00b788ffe 100644 --- a/codex-rs/tui/src/app/thread_workflow_actions.rs +++ b/codex-rs/tui/src/app/thread_workflow_actions.rs @@ -4,6 +4,37 @@ use crate::app_server_session::AppServerSession; use codex_protocol::ThreadId; impl App { + pub(super) async fn open_thread_workflow_manager( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) { + self.chat_widget + .show_thread_workflow_manager_loading(thread_id); + + let workflow_response = app_server.thread_workflow_list(thread_id).await; + let run_response = app_server.thread_workflow_run_list(thread_id).await; + if self.current_displayed_thread_id() != Some(thread_id) { + return; + } + + match (workflow_response, run_response) { + (Ok(workflows), Ok(runs)) => self + .chat_widget + .show_thread_workflow_manager(thread_id, workflows, runs), + (Err(err), _) => self.chat_widget.show_thread_workflow_manager_error( + thread_id, + "read workflow specs", + &err, + ), + (_, Err(err)) => self.chat_widget.show_thread_workflow_manager_error( + thread_id, + "list workflow runs", + &err, + ), + } + } + pub(super) async fn manage_thread_workflow( &mut self, app_server: &mut AppServerSession, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 499a0d8c0..efc31ff8b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -37,6 +37,8 @@ use codex_app_server_protocol::ThreadPendingInteractionTerminalStatus; use codex_app_server_protocol::ThreadQueuedMessageMoveDirection; use codex_app_server_protocol::ThreadSchedulePromptSource; use codex_app_server_protocol::ThreadScheduleSpec; +use codex_app_server_protocol::ThreadWorkflow; +use codex_app_server_protocol::ThreadWorkflowRun; use codex_app_server_protocol::WebhookEventStatus; use codex_file_search::FileMatch; use codex_protocol::ThreadId; @@ -436,6 +438,14 @@ pub(crate) enum AppEvent { origin: MiniMaxUsageRefreshOrigin, }, + /// Open the top-level changelog browser. + OpenChangelogBrowser, + + /// Open one release in the changelog browser. + OpenChangelogRelease { + version: String, + }, + /// Open the current thread goal summary/action menu. OpenThreadGoalMenu { thread_id: ThreadId, @@ -463,6 +473,26 @@ pub(crate) enum AppEvent { action: ThreadWorkflowAction, }, + /// Open the interactive thread workflow manager. + OpenThreadWorkflowManager { + thread_id: ThreadId, + }, + + /// Open a prompt that prepares a workflow YAML draft request. + OpenThreadWorkflowDraftPrompt, + + /// Open actions for a saved workflow spec. + OpenThreadWorkflowActions { + thread_id: ThreadId, + workflow: ThreadWorkflow, + }, + + /// Open actions for a workflow run. + OpenThreadWorkflowRunActions { + thread_id: ThreadId, + run: ThreadWorkflowRun, + }, + /// Open details for one durable thread goal plan. OpenThreadGoalPlanDetail { thread_id: ThreadId, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a17707c5b..4e72e0b49 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -374,6 +374,7 @@ mod mission_control_menu; mod monitor_display; mod webhook_display; mod workflow_display; +mod workflow_manager; mod workflow_slash; mod worktree_display; use self::ide_context::IdeContextState; @@ -620,6 +621,10 @@ pub(crate) struct ChatWidget { BTreeMap, BTreeMap>, auth_profile_usage_heartbeat_requested_at_by_profile: BTreeMap, Instant>, auth_profile_usage_heartbeat_failed_at_by_profile: BTreeMap, Instant>, + /// Earliest known future reset (unix seconds) for a profile whose cached codex usage is + /// exhausted. While the reset is in the future we suppress usage heartbeats for that + /// profile instead of re-polling a limit we already know is capped. + auth_profile_usage_exhausted_reset_at_by_profile: BTreeMap, i64>, rate_limit_poller: Option>, auth_profile_auto_switch_snapshots_by_limit_id: BTreeMap, refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, diff --git a/codex-rs/tui/src/chatwidget/auth_profile_popups.rs b/codex-rs/tui/src/chatwidget/auth_profile_popups.rs index 32307d1ee..4eef3e010 100644 --- a/codex-rs/tui/src/chatwidget/auth_profile_popups.rs +++ b/codex-rs/tui/src/chatwidget/auth_profile_popups.rs @@ -22,7 +22,8 @@ use std::time::Duration; use std::time::Instant; const AUTH_PROFILE_LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); -const AUTH_PROFILE_USAGE_HEARTBEAT_FAILURE_BACKOFF: Duration = Duration::from_secs(5 * 60); +pub(super) const AUTH_PROFILE_USAGE_HEARTBEAT_FAILURE_BACKOFF: Duration = + Duration::from_secs(5 * 60); const AUTH_PROFILE_POPUP_VIEW_ID: &str = "auth-profile-selection"; impl ChatWidget { @@ -455,6 +456,19 @@ impl ChatWidget { } let profile = profile.map(str::to_string); + // Reset-aware suppression: if the profile's cached codex usage is exhausted and its + // reported reset is still in the future, do not re-poll the usage endpoint. Nothing + // will change until the reset, so polling every interval just hammers a limit we + // already know about. Once the reset elapses (or if it was never reported) this + // check falls through to the normal interval/backoff below, so heartbeats resume. + if self + .auth_profile_usage_exhausted_reset_at_by_profile + .get(&profile) + .is_some_and(|reset_at| *reset_at > chrono::Utc::now().timestamp()) + { + return false; + } + if self .auth_profile_usage_heartbeat_failed_at_by_profile .get(&profile) diff --git a/codex-rs/tui/src/chatwidget/changelog.rs b/codex-rs/tui/src/chatwidget/changelog.rs index 9418c7267..bbe0fd613 100644 --- a/codex-rs/tui/src/chatwidget/changelog.rs +++ b/codex-rs/tui/src/chatwidget/changelog.rs @@ -1,39 +1,414 @@ -//! `/changelog` transcript output. +//! `/changelog` promptbar browser. use super::*; +use crate::bottom_pane::SelectionRowDisplay; const CHANGELOG_SOURCE: &str = include_str!("../../../../CHANGELOG.md"); const RELEASE_SECTION_START: &str = "## [Unreleased]"; +pub(crate) const CHANGELOG_BROWSER_VIEW_ID: &str = "changelog-browser"; impl ChatWidget { - pub(crate) fn add_changelog_output(&mut self) { - self.add_to_history(history_cell::AgentMarkdownCell::new( - changelog_markdown(), - &self.config.cwd, - )); + pub(crate) fn open_changelog_browser(&mut self) { + self.replace_or_show_changelog_view(changelog_browser_params()); } + + pub(crate) fn open_changelog_release(&mut self, version: String) { + self.replace_or_show_changelog_view(changelog_release_params(&version)); + } + + fn replace_or_show_changelog_view(&mut self, params: SelectionViewParams) { + if self.bottom_pane.active_view_id() == Some(CHANGELOG_BROWSER_VIEW_ID) { + let _ = self + .bottom_pane + .replace_selection_view_if_active(CHANGELOG_BROWSER_VIEW_ID, params); + } else { + self.bottom_pane.show_selection_view(params); + } + self.request_redraw(); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ChangelogRelease { + version: String, + heading: String, + date: Option, + bullets: Vec, +} + +fn changelog_browser_params() -> SelectionViewParams { + changelog_browser_params_for(changelog_releases()) +} + +fn changelog_browser_params_for(releases: Vec) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Codewith Changelog".bold())); + header.push(Line::from( + "Select a release to view its changelog bullets.".dim(), + )); + + let items = if releases.is_empty() { + vec![SelectionItem { + name: "No releases found".to_string(), + description: Some("CHANGELOG.md does not contain release headings.".to_string()), + is_disabled: true, + ..Default::default() + }] + } else { + releases + .into_iter() + .map(|release| { + let version = release.version; + let search_value = Some(format!( + "{} {}", + version, + release.date.clone().unwrap_or_default() + )); + SelectionItem { + name: version.clone(), + description: release.date, + selected_description: Some(format!("Open {version} release notes.")), + search_value, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenChangelogRelease { + version: version.clone(), + }); + })], + ..Default::default() + } + }) + .collect() + }; + + SelectionViewParams { + view_id: Some(CHANGELOG_BROWSER_VIEW_ID), + header: Box::new(header), + items, + is_searchable: true, + search_placeholder: Some("Search versions".to_string()), + col_width_mode: ColumnWidthMode::Fixed, + row_display: SelectionRowDisplay::SingleLine, + ..Default::default() + } +} + +fn changelog_release_params(version: &str) -> SelectionViewParams { + changelog_release_params_for(version, changelog_releases()) +} + +fn changelog_release_params_for( + version: &str, + releases: Vec, +) -> SelectionViewParams { + let release = releases + .into_iter() + .find(|release| release.version == version); + let mut header = ColumnRenderable::new(); + let title = release + .as_ref() + .map(|release| release.heading.as_str()) + .unwrap_or(version); + header.push(Line::from(format!("Codewith {title}").bold())); + header.push(Line::from("Bullets copied from CHANGELOG.md.".dim())); + + let mut items = vec![ + SelectionItem { + name: "Back".to_string(), + description: Some("Return to all changelog versions.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenChangelogBrowser); + })], + ..Default::default() + }, + SelectionItem { + name: "Close".to_string(), + description: Some("Close the changelog browser.".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ]; + + match release { + Some(release) if release.bullets.is_empty() => { + items.push(SelectionItem { + name: "No bullet entries recorded".to_string(), + description: Some( + "This release section has no bullet items in CHANGELOG.md.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + Some(release) => { + items.extend(release.bullets.into_iter().map(|bullet| SelectionItem { + name: format!("- {bullet}"), + search_value: Some(bullet), + is_disabled: true, + ..Default::default() + })); + } + None => { + items.push(SelectionItem { + name: "Release not found".to_string(), + description: Some( + "The selected version is not present in CHANGELOG.md.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + } + + SelectionViewParams { + view_id: Some(CHANGELOG_BROWSER_VIEW_ID), + header: Box::new(header), + items, + is_searchable: false, + col_width_mode: ColumnWidthMode::Fixed, + row_display: SelectionRowDisplay::Wrapped, + ..Default::default() + } +} + +fn changelog_releases() -> Vec { + changelog_releases_from_source(CHANGELOG_SOURCE) +} + +fn changelog_releases_from_source(source: &str) -> Vec { + let mut releases = Vec::::new(); + for line in release_notes_source(source).split_inclusive('\n') { + if let Some((version, date)) = parse_release_heading(line) { + releases.push(ChangelogRelease { + version, + heading: line.trim_start_matches("## ").trim().to_string(), + date, + bullets: Vec::new(), + }); + continue; + } + + if line.trim().starts_with("## ") && !releases.is_empty() { + break; + } + + if let Some(release) = releases.last_mut() { + push_bullet_line(&mut release.bullets, line); + } + } + releases } -fn changelog_markdown() -> String { - let release_notes = CHANGELOG_SOURCE +fn release_notes_source(source: &str) -> &str { + source .find(RELEASE_SECTION_START) - .map(|idx| &CHANGELOG_SOURCE[idx..]) - .unwrap_or(CHANGELOG_SOURCE) - .trim(); + .map(|idx| &source[idx..]) + .unwrap_or(source) + .trim() +} - format!("# Codewith Changelog\n\n{release_notes}") +fn parse_release_heading(line: &str) -> Option<(String, Option)> { + let heading = line.trim().strip_prefix("## ")?; + let version_end = heading.find(']').filter(|_| heading.starts_with('['))?; + let version = heading.get(1..version_end)?.to_string(); + let date = heading + .get(version_end + 1..) + .and_then(|rest| rest.trim().strip_prefix("- ")) + .map(str::trim) + .filter(|date| !date.is_empty()) + .map(str::to_string); + Some((version, date)) +} + +fn push_bullet_line(bullets: &mut Vec, line: &str) { + let trimmed = line.trim(); + if trimmed.starts_with("- ") { + bullets.push(trimmed.trim_start_matches("- ").trim().to_string()); + } else if let Some(last) = bullets.last_mut() + && !trimmed.is_empty() + && !trimmed.starts_with('#') + && !trimmed.starts_with("Tag:") + && !trimmed.starts_with("npm:") + && !trimmed.starts_with("Compare:") + { + last.push(' '); + last.push_str(trimmed); + } } #[cfg(test)] mod tests { use super::*; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + const TEST_CHANGELOG_SOURCE: &str = r#" +# Changelog + +Known evidence gaps: + +- Known evidence gaps should not be listed as release bullets. + +## [Unreleased] + +## [0.1.55] - 2026-07-02 + +### Fixed + +- Release pipeline: libcap-2.75 musl download now falls back across verified + mirrors (kernel.org -> OSUOSL -> Debian) with mandatory checksum; macOS builds + use CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 and the aarch64 primary runs on + macos-15-xlarge, ending the 3-hour timeouts that killed 0.1.52-0.1.54. +- Goals state: databases stamped by published 0.1.48 are repaired before + migration, preventing a permanent VersionMismatch(5) that disabled the sqlite + state layer (threads, schedules, mailbox, goals) after upgrade. +- OpenRouter GLM-5.2 metadata restored (parallel tool calls, High/XHigh + reasoning presets, gpt-oss-120b as default fallback). +- Usage-profile broker: scheduled dispatches now defer while all sibling + profiles are cooling down instead of feeding the failure circuit breaker. +- Remote and sandboxed filesystems support symlink-safe reads, so project + instructions (CODEWITH.md/AGENTS.md) load in remote sessions. +- Mailbox: queued-input API restored; delivery defer rolls back on enqueue + rejection; delivery-policy parsing matches the SQL claim filter; queued + context delivery is bounded. + +## [0.1.54] - 2026-07-02 + +- Placeholder 0.1.54 release note. + +## [0.1.53] - 2026-07-01 + +- Placeholder 0.1.53 release note. + +## [0.1.52] - 2026-07-01 + +- Placeholder 0.1.52 release note. + +## [0.1.51] - 2026-06-26 + +- Placeholder 0.1.51 release note. + +## [0.1.50] - 2026-06-26 + +- Placeholder 0.1.50 release note. + +## [0.1.49] - 2026-06-26 + +- Placeholder 0.1.49 release note. + +## [0.1.0] - 2026-05-21 + +- Initial fork release. + +## Maintenance Process + +- Determine published npm versions. +"#; + + fn fixture_releases() -> Vec { + changelog_releases_from_source(TEST_CHANGELOG_SOURCE) + } #[test] - fn changelog_output_uses_release_sections_without_repository_notes() { - let markdown = changelog_markdown(); + fn changelog_releases_use_release_sections_without_repository_notes() { + let releases = changelog_releases(); + + assert_eq!( + releases.first().map(|release| release.version.as_str()), + Some("Unreleased") + ); + assert!( + releases + .iter() + .any(|release| release.version.starts_with("0.1.")) + ); + assert!( + releases + .iter() + .flat_map(|release| release.bullets.iter()) + .all(|bullet| !bullet.contains("Known evidence gaps")) + ); + } + + #[test] + fn changelog_release_parser_collects_real_bullets() { + let releases = fixture_releases(); + let latest_release = releases + .iter() + .find(|release| release.version == "0.1.55") + .expect("versioned release"); + + assert!(!latest_release.bullets.is_empty()); + assert!( + latest_release + .bullets + .iter() + .any(|bullet| bullet.contains("Release pipeline")), + "expected parsed real release bullet, got {latest_release:#?}" + ); + } + + #[test] + fn changelog_release_parser_stops_before_maintenance_process() { + let releases = fixture_releases(); + let initial_release = releases + .iter() + .find(|release| release.version == "0.1.0") + .expect("initial release"); + + assert!( + initial_release.bullets.iter().all(|bullet| !bullet + .contains("Determine published npm versions") + && !bullet.contains("Maintenance Process")), + "expected only release bullets, got {initial_release:#?}" + ); + } + + #[test] + fn changelog_browser_versions_snapshot() { + let view = selection_view(changelog_browser_params_for(fixture_releases())); + + insta::assert_snapshot!( + "changelog_browser_versions", + render_lines(&view, /*width*/ 72) + ); + } + + #[test] + fn changelog_browser_release_snapshot() { + let view = selection_view(changelog_release_params_for("0.1.55", fixture_releases())); + + insta::assert_snapshot!( + "changelog_browser_release_bullets", + render_lines(&view, /*width*/ 88) + ); + } + + fn selection_view(params: SelectionViewParams) -> ListSelectionView { + let (tx_raw, _rx) = unbounded_channel::(); + ListSelectionView::new( + params, + AppEventSender::new(tx_raw), + crate::keymap::RuntimeKeymap::defaults().list, + ) + } + + fn render_lines(view: &ListSelectionView, width: u16) -> String { + let area = Rect::new(0, 0, width, view.desired_height(width)); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); - assert!(markdown.starts_with("# Codewith Changelog\n\n## [Unreleased]")); - assert!(markdown.contains("## [0.1.")); - assert!(!markdown.contains("Known evidence gaps")); + (0..area.height) + .map(|y| { + let mut line = String::new(); + for x in 0..area.width { + line.push_str(buf[(x, y)].symbol()); + } + line.trim_end().to_string() + }) + .collect::>() + .join("\n") } } diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index ab0eb290c..9e3376259 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -132,6 +132,7 @@ impl ChatWidget { auth_profile_rate_limit_snapshots_by_profile: BTreeMap::new(), auth_profile_usage_heartbeat_requested_at_by_profile: BTreeMap::new(), auth_profile_usage_heartbeat_failed_at_by_profile: BTreeMap::new(), + auth_profile_usage_exhausted_reset_at_by_profile: BTreeMap::new(), rate_limit_poller: None, auth_profile_auto_switch_snapshots_by_limit_id: BTreeMap::new(), refreshing_status_outputs: Vec::new(), diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 23b9fc763..3f2e45d44 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -1,9 +1,11 @@ //! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`. use super::*; +use crate::chatwidget::auth_profile_popups::AUTH_PROFILE_USAGE_HEARTBEAT_FAILURE_BACKOFF; use crate::chatwidget::usage_profile_broker::UsageProfileAutoSwitchWindow; use crate::chatwidget::usage_profile_broker::auth_profile_auto_switch_target as broker_auth_profile_auto_switch_target; use crate::chatwidget::usage_profile_broker::auto_switch_trigger_key; +use crate::chatwidget::usage_profile_broker::earliest_exhausted_reset_at; use crate::chatwidget::usage_profile_broker::exhausted_auto_switch_window; use crate::chatwidget::usage_profile_broker::exhausted_auto_switch_window_for_snapshot; use crate::chatwidget::usage_profile_broker::fallback_limit_label as broker_fallback_limit_label; @@ -146,7 +148,7 @@ impl ChatWidget { ) { let captured_at = Local::now(); let mut displays = BTreeMap::new(); - for snapshot in snapshots { + for snapshot in &snapshots { let limit_id = snapshot .limit_id .clone() @@ -157,10 +159,12 @@ impl ChatWidget { .unwrap_or_else(|| limit_id.clone()); displays.insert( limit_id, - rate_limit_snapshot_display_for_limit(&snapshot, limit_label, captured_at), + rate_limit_snapshot_display_for_limit(snapshot, limit_label, captured_at), ); } + self.update_auth_profile_usage_exhaustion(&profile, &snapshots); + if displays.is_empty() { self.auth_profile_rate_limit_snapshots_by_profile .remove(&profile); @@ -170,6 +174,37 @@ impl ChatWidget { } } + /// Record whether `profile`'s codex usage is exhausted with a future reset so we can + /// suppress usage heartbeats for it until it resets (see + /// `should_request_auth_profile_usage_heartbeat`). + fn update_auth_profile_usage_exhaustion( + &mut self, + profile: &Option, + snapshots: &[RateLimitSnapshot], + ) { + let now = chrono::Utc::now().timestamp(); + let reset_at = snapshots + .iter() + .filter_map(|snapshot| { + let is_codex_limit = snapshot + .limit_id + .as_deref() + .is_none_or(|limit_id| limit_id.eq_ignore_ascii_case("codex")); + earliest_exhausted_reset_at(snapshot, is_codex_limit, now) + }) + .min(); + match reset_at { + Some(reset_at) => { + self.auth_profile_usage_exhausted_reset_at_by_profile + .insert(profile.clone(), reset_at); + } + None => { + self.auth_profile_usage_exhausted_reset_at_by_profile + .remove(profile); + } + } + } + pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) { // Rolling app-server notifications are sparse. Preserve metadata learned from the full read. self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate); @@ -277,6 +312,13 @@ impl ChatWidget { } self.update_auth_profile_auto_switch_snapshot(&limit_id, &snapshot, is_codex_limit); + if is_codex_limit { + let selected_profile = self.config.selected_auth_profile.clone(); + self.update_auth_profile_usage_exhaustion( + &selected_profile, + std::slice::from_ref(&snapshot), + ); + } if !self.usage_limit_reset_takes_precedence_for_snapshot(&snapshot) { self.maybe_auto_switch_auth_profile_for_rate_limit(&limit_id, &snapshot); } @@ -303,6 +345,8 @@ impl ChatWidget { self.rate_limit_snapshots_by_limit_id.clear(); self.auth_profile_rate_limit_snapshots_by_profile .remove(&self.config.selected_auth_profile); + self.auth_profile_usage_exhausted_reset_at_by_profile + .remove(&self.config.selected_auth_profile); self.auth_profile_auto_switch_snapshots_by_limit_id.clear(); self.codex_rate_limit_reached_type = None; } @@ -435,6 +479,18 @@ impl ChatWidget { self.refresh_pending_input_preview(); } + /// Named profiles whose most recent usage heartbeat failed within the failure backoff. + /// Used to avoid auto-switching onto a profile we could not confirm is available. + fn recently_failed_auth_profile_usage_heartbeats(&self) -> std::collections::HashSet { + self.auth_profile_usage_heartbeat_failed_at_by_profile + .iter() + .filter(|(_, failed_at)| { + failed_at.elapsed() < AUTH_PROFILE_USAGE_HEARTBEAT_FAILURE_BACKOFF + }) + .filter_map(|(profile, _)| profile.clone()) + .collect() + } + fn auth_profile_auto_switch_target( &mut self, limit_id: &str, @@ -459,11 +515,13 @@ impl ChatWidget { return None; } }; + let recently_failed = self.recently_failed_auth_profile_usage_heartbeats(); if let Some(target) = broker_auth_profile_auto_switch_target( &self.config.auth_profile_auto_switch, self.config.selected_auth_profile.as_deref(), &profiles, &self.auth_profile_rate_limit_snapshots_by_profile, + &recently_failed, limit_id, window, ) { diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 5876c4d0a..dfe332d24 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -1172,11 +1172,9 @@ impl ChatWidget { return; } if let Some(thread_id) = self.thread_id { - self.app_event_tx.send(AppEvent::ManageThreadWorkflow { - thread_id, - action: ThreadWorkflowAction::List, - }); - self.append_message_history_entry("/workflow".to_string()); + self.app_event_tx + .send(AppEvent::OpenThreadWorkflowManager { thread_id }); + self.append_message_history_entry(format!("/{}", cmd.command())); } else { self.add_info_message( WORKFLOW_USAGE.to_string(), @@ -1423,7 +1421,7 @@ impl ChatWidget { self.open_usage_panel(); } SlashCommand::Changelog => { - self.add_changelog_output(); + self.open_changelog_browser(); } SlashCommand::Ide => { self.handle_ide_command(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__changelog__tests__changelog_browser_release_bullets.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__changelog__tests__changelog_browser_release_bullets.snap new file mode 100644 index 000000000..77901d662 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__changelog__tests__changelog_browser_release_bullets.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/chatwidget/changelog.rs +assertion_line: 289 +expression: "render_lines(&view, 88)" +--- + + Codewith [0.1.55] - 2026-07-02 + Bullets copied from CHANGELOG.md. + +› 1. Back Return to all changelog versions. + 2. Close Close the changelog browser. + - Release pipeline: libcap-2.75 musl download now falls back across verified + mirrors (kernel.org -> OSUOSL -> Debian) with mandatory checksum; macOS builds + use CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 and the aarch64 primary runs on macos- + 15-xlarge, ending the 3-hour timeouts that killed 0.1.52-0.1.54. + - Goals state: databases stamped by published 0.1.48 are repaired before + migration, preventing a permanent VersionMismatch(5) that disabled the sqlite + state layer (threads, schedules, mailbox, goals) after upgrade. + - OpenRouter GLM-5.2 metadata restored (parallel tool calls, High/XHigh reasoning + presets, gpt-oss-120b as default fallback). + - Usage-profile broker: scheduled dispatches now defer while all sibling profiles + are cooling down instead of feeding the failure circuit breaker. + - Remote and sandboxed filesystems support symlink-safe reads, so project + instructions (CODEWITH.md/AGENTS.md) load in remote sessions. + - Mailbox: queued-input API restored; delivery defer rolls back on enqueue + rejection; delivery-policy parsing matches the SQL claim filter; queued context + delivery is bounded. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__changelog__tests__changelog_browser_versions.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__changelog__tests__changelog_browser_versions.snap new file mode 100644 index 000000000..320be3c72 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__changelog__tests__changelog_browser_versions.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/changelog.rs +expression: "render_lines(&view, 72)" +--- + + Codewith Changelog + Select a release to view its changelog bullets. + + Search versions +› Unreleased Open Unreleased release notes. + 0.1.55 2026-07-02 + 0.1.54 2026-07-02 + 0.1.53 2026-07-01 + 0.1.52 2026-07-01 + 0.1.51 2026-06-26 + 0.1.50 2026-06-26 + 0.1.49 2026-06-26 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_changelog_release_notes_preview.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_changelog_release_notes_preview.snap deleted file mode 100644 index 33f935a22..000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_changelog_release_notes_preview.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/chatwidget/tests/slash_commands.rs -expression: preview ---- -• # Codewith Changelog - - ## [Unreleased] - - ## [0.1.68] - 2026-07-20 - - Tag: rust-v0.1.68 - npm: https://www.npmjs.com/package/@hasna/codewith/v/0.1.68 - (https://www.npmjs.com/package/@hasna/codewith/v/0.1.68) - Compare: https://github.com/hasna/codewith/compare/rust-v0.1.67...rust-v0.1.68 - (https://github.com/hasna/codewith/compare/rust-v0.1.67...rust-v0.1.68) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_active_runs.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_active_runs.snap new file mode 100644 index 000000000..9769e1320 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_active_runs.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/workflow_manager.rs +expression: "render_bottom_popup(&chat, 120)" +--- + Specs (1) [Runs (1)] Approvals + + Search workflows +› spec_workflow-alpha · waiting ·… 2 agent steps active, 1 steps waiting for verifiers, 9 monitor/activity events · 4 + verifiers · 9 events · workflow workflow-alp + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_completed_run_actions.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_completed_run_actions.snap new file mode 100644 index 000000000..0d9ba8123 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_completed_run_actions.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests/workflow_manager.rs +expression: "render_bottom_popup(&chat, 110)" +--- + Run run-complete + 9 monitor/activity events · 4 verifiers · 9 events · workflow workflow-alp + +› 1. Inspect run Show step, verifier, and event progress + Stop (disabled) Request cancellation for this workflow run (disabled: Completed workflow + runs cannot be stopped) + Review approva… (disabled) Workflow-specific approval review is not available yet (disabled: Approval + review uses the existing global approval popups) + 2. Back to workflows Return to specs and runs + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_empty.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_empty.snap new file mode 100644 index 000000000..96dff94ce --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_empty.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/workflow_manager.rs +expression: "render_bottom_popup(&chat, 110)" +--- + [Specs (0)] Runs (0) Approvals + + Search workflows +› Create from prompt Draft workflow YAML from a natural-language request + No saved workflow specs Create one from a prompt or use /workflow draft + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_error.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_error.snap new file mode 100644 index 000000000..2179c5aaf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_error.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/workflow_manager.rs +expression: "render_bottom_popup(&chat, 105)" +--- + Workflows + Failed to read workflow specs + +› 1. Retry Refresh workflow specs and runs + Workflow manager unavai… ephemeral thread does not support workflows: thread-1 + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_loading.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_loading.snap new file mode 100644 index 000000000..601a65dda --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__workflow_manager_loading.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/workflow_manager.rs +expression: "render_bottom_popup(&chat, 100)" +--- + Workflows + Loading workflow specs and runs + +› 1. Loading workflows Fetching saved specs and recent runs for this thread + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 7ccefc9ac..f98e178e1 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -245,6 +245,7 @@ mod status_surface_previews; mod terminal_title; mod usage_limit_reset_tests; mod webhook; +mod workflow_manager; mod worktree_display; pub(crate) use helpers::make_chatwidget_manual_with_sender; 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 ba689f917..025a8664a 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3317,6 +3317,43 @@ async fn usage_heartbeat_failure_backs_off_profile_refresh() { ); } +#[tokio::test] +async fn usage_heartbeat_suppressed_for_exhausted_profile_until_reset() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + save_popup_chatgpt_auth_profile(&chat, "work", "work@example.com"); + + // A cached-but-stale exhausted snapshot whose reset is still in the future suppresses the + // heartbeat: nothing will change until the reset, so we must not re-poll the usage endpoint. + let stale_exhausted = rate_limit_snapshot_display_for_limit( + &profile_usage_snapshot( + /*secondary_used_percent*/ 100, /*primary_used_percent*/ 100, + ), + "codex".to_string(), + Local::now() - chrono::Duration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES + 1), + ); + chat.auth_profile_rate_limit_snapshots_by_profile.insert( + Some("work".to_string()), + BTreeMap::from([("codex".to_string(), stale_exhausted)]), + ); + chat.auth_profile_usage_exhausted_reset_at_by_profile + .insert( + Some("work".to_string()), + chrono::Utc::now().timestamp() + 3600, + ); + + assert!(chat.auth_profile_usage_refresh_targets().is_empty()); + + // Once the reset has elapsed the suppression lapses and heartbeats resume (graceful + // fallback so a missing/stale reset can never permanently block the profile). + chat.auth_profile_usage_exhausted_reset_at_by_profile + .insert(Some("work".to_string()), chrono::Utc::now().timestamp() - 1); + assert_eq!( + chat.auth_profile_usage_refresh_targets(), + vec![RateLimitRefreshTarget::Named("work".to_string())] + ); +} + fn assert_no_rate_limit_refresh_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver) { while let Ok(event) = rx.try_recv() { assert!( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 6e61b1ae4..f7897d348 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2347,6 +2347,27 @@ async fn workflow_list_slash_command_emits_metadata_event() { assert_eq!(recall_latest_after_clearing(&mut chat), command); } +#[tokio::test] +async fn workflows_alias_opens_interactive_manager() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Workflows, /*enabled*/ true); + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + + submit_composer_text(&mut chat, "/workflows"); + + let event = rx.try_recv().expect("expected workflow manager event"); + let AppEvent::OpenThreadWorkflowManager { + thread_id: actual_thread_id, + } = event + else { + panic!("expected OpenThreadWorkflowManager, got {event:?}"); + }; + assert_eq!(actual_thread_id, thread_id); + assert_no_submit_op(&mut op_rx); + assert_eq!(recall_latest_after_clearing(&mut chat), "/workflows"); +} + #[tokio::test] async fn workflow_run_slash_commands_emit_management_events() { let cases = [ @@ -3468,33 +3489,55 @@ async fn slash_exit_requests_exit() { } #[tokio::test] -async fn slash_changelog_prints_release_notes() { +async fn slash_changelog_opens_browser_without_submitting_prompt() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); + + chat.bottom_pane + .set_composer_text("/changelog".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_eq!(chat.bottom_pane.active_view_id(), Some("changelog-browser")); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!(chat.bottom_pane.is_task_running()); + + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert_eq!(chat.bottom_pane.active_view_id(), None); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!(chat.bottom_pane.is_task_running()); +} +#[tokio::test] +async fn slash_changelog_drills_back_and_closes_without_core_ops() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); chat.dispatch_command(SlashCommand::Changelog); - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one changelog history cell"); - let rendered = lines_to_single_string(&cells[0]); - let preview = lines_to_single_string(&cells[0][..cells[0].len().min(12)]) - .lines() - .map(str::trim_end) - .collect::>() - .join("\n"); - assert_chatwidget_snapshot!("slash_changelog_release_notes_preview", preview); - assert!( - rendered.contains("Codewith Changelog"), - "expected changelog title, got {rendered:?}" - ); - assert!( - rendered.contains("Unreleased"), - "expected unreleased section, got {rendered:?}" - ); - assert!( - !rendered.contains("Known evidence gaps"), - "expected repository notes to be omitted, got {rendered:?}" - ); - assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let version = match rx.try_recv() { + Ok(AppEvent::OpenChangelogRelease { version }) => version, + other => panic!("expected changelog release event, got {other:?}"), + }; + chat.open_changelog_release(version); + assert_eq!(chat.bottom_pane.active_view_id(), Some("changelog-browser")); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenChangelogBrowser)); + chat.open_changelog_browser(); + assert_eq!(chat.bottom_pane.active_view_id(), Some("changelog-browser")); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + chat.open_changelog_release("0.1.55".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!(chat.bottom_pane.active_view_id(), None); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!(chat.bottom_pane.is_task_running()); } #[tokio::test] 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 5d80476e9..cd0ae03eb 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1627,6 +1627,38 @@ async fn auto_switch_skips_known_exhausted_profile_for_unknown_candidate() { } } +#[tokio::test] +async fn auto_switch_skips_recently_failed_profile_for_unknown_candidate() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + save_test_auth_profile(&chat, "work"); + save_test_auth_profile(&chat, "personal"); + save_test_auth_profile(&chat, "backup"); + chat.config.selected_auth_profile = Some("work".to_string()); + chat.config.auth_profile_auto_switch.enabled = true; + chat.config.auth_profile_auto_switch.profiles = vec![ + "work".to_string(), + "personal".to_string(), + "backup".to_string(), + ]; + configure_test_session(&mut chat); + drain_insert_history(&mut rx); + + // "personal" just failed a usage heartbeat, so it must not be chosen even though it is + // earlier in the configured order and otherwise Unknown; "backup" (also Unknown) wins. + chat.record_auth_profile_usage_heartbeat_failure(Some("personal".to_string())); + + chat.on_rate_limit_snapshot(Some(rate_limit_snapshot_for_window( + /*used_percent*/ 100, /*window_duration_mins*/ 300, /*resets_at*/ 123, + ))); + + match rx.try_recv() { + Ok(AppEvent::SwitchAuthProfile { profile, .. }) => { + assert_eq!(profile.as_deref(), Some("backup")); + } + other => panic!("expected auth profile switch event, got {other:?}"), + } +} + #[tokio::test] async fn cached_exhausted_limit_auto_switches_before_next_prompt_after_being_enabled() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/workflow_manager.rs b/codex-rs/tui/src/chatwidget/tests/workflow_manager.rs new file mode 100644 index 000000000..176681cf5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/workflow_manager.rs @@ -0,0 +1,268 @@ +use super::*; +use codex_app_server_protocol::ThreadWorkflow; +use codex_app_server_protocol::ThreadWorkflowListResponse; +use codex_app_server_protocol::ThreadWorkflowRun; +use codex_app_server_protocol::ThreadWorkflowRunListResponse; +use codex_app_server_protocol::ThreadWorkflowRunStatus; +use codex_app_server_protocol::ThreadWorkflowStatus; + +fn test_workflow(id: &str, status: ThreadWorkflowStatus) -> ThreadWorkflow { + ThreadWorkflow { + thread_id: "thread-1".to_string(), + workflow_record_id: id.to_string(), + spec_workflow_id: format!("spec_{id}"), + schema_version: "workflow.codex.codewith/v0".to_string(), + display_name: format!("Workflow {id}"), + status, + source_yaml_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + .to_string(), + agent_count: 3, + step_count: 7, + parallel_group_count: 2, + verifier_count: 4, + run_command_verifier_count: 1, + model_routed_step_count: 6, + created_at: 1_800_000_000, + updated_at: 1_800_000_123, + } +} + +fn test_run(id: &str, status: ThreadWorkflowRunStatus) -> ThreadWorkflowRun { + ThreadWorkflowRun { + thread_id: Some("thread-1".to_string()), + run_id: id.to_string(), + workflow_record_id: "workflow-alpha".to_string(), + spec_workflow_id: "spec_workflow-alpha".to_string(), + schema_version: "workflow.codex.codewith/v0".to_string(), + source_yaml_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + .to_string(), + status, + status_reason: None, + reason_code: None, + generation: 1, + pending_step_count: 1, + ready_step_count: 1, + active_step_count: if matches!( + status, + ThreadWorkflowRunStatus::Running | ThreadWorkflowRunStatus::Waiting + ) { + 2 + } else { + 0 + }, + waiting_verifier_step_count: if status == ThreadWorkflowRunStatus::Waiting { + 1 + } else { + 0 + }, + blocked_step_count: 0, + failed_step_count: if status == ThreadWorkflowRunStatus::Failed { + 1 + } else { + 0 + }, + succeeded_step_count: if status == ThreadWorkflowRunStatus::Completed { + 7 + } else { + 3 + }, + skipped_step_count: 0, + verifier_count: 4, + event_count: 9, + created_at: 1_800_000_000, + updated_at: 1_800_000_456, + started_at: Some(1_800_000_010), + completed_at: (status == ThreadWorkflowRunStatus::Completed).then_some(1_800_000_456), + } +} + +#[tokio::test] +async fn workflow_manager_empty_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_thread_workflow_manager( + thread_id, + ThreadWorkflowListResponse { + data: Vec::new(), + next_cursor: None, + }, + ThreadWorkflowRunListResponse { + data: Vec::new(), + next_cursor: None, + }, + ); + + assert_chatwidget_snapshot!( + "workflow_manager_empty", + render_bottom_popup(&chat, /*width*/ 110) + ); +} + +#[tokio::test] +async fn workflow_manager_loading_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.show_thread_workflow_manager_loading(ThreadId::new()); + + assert_chatwidget_snapshot!( + "workflow_manager_loading", + render_bottom_popup(&chat, /*width*/ 100) + ); +} + +#[tokio::test] +async fn workflow_manager_error_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let err = color_eyre::eyre::eyre!("ephemeral thread does not support workflows: thread-1"); + + chat.show_thread_workflow_manager_error(ThreadId::new(), "read workflow specs", &err); + + assert_chatwidget_snapshot!( + "workflow_manager_error", + render_bottom_popup(&chat, /*width*/ 105) + ); +} + +#[tokio::test] +async fn workflow_manager_active_runs_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_thread_workflow_manager( + thread_id, + ThreadWorkflowListResponse { + data: vec![test_workflow("workflow-alpha", ThreadWorkflowStatus::Draft)], + next_cursor: None, + }, + ThreadWorkflowRunListResponse { + data: vec![test_run( + "run-active-123456789", + ThreadWorkflowRunStatus::Waiting, + )], + next_cursor: None, + }, + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + + assert_chatwidget_snapshot!( + "workflow_manager_active_runs", + render_bottom_popup(&chat, /*width*/ 120) + ); +} + +#[tokio::test] +async fn workflow_manager_completed_run_actions_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.show_thread_workflow_run_actions( + ThreadId::new(), + test_run( + "run-completed-123456789", + ThreadWorkflowRunStatus::Completed, + ), + ); + + assert_chatwidget_snapshot!( + "workflow_manager_completed_run_actions", + render_bottom_popup(&chat, /*width*/ 110) + ); +} + +#[tokio::test] +async fn workflow_manager_loaded_replaces_loading_view() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + + chat.show_thread_workflow_manager_loading(thread_id); + chat.show_thread_workflow_manager( + thread_id, + ThreadWorkflowListResponse { + data: vec![test_workflow("workflow-alpha", ThreadWorkflowStatus::Draft)], + next_cursor: None, + }, + ThreadWorkflowRunListResponse { + data: Vec::new(), + next_cursor: None, + }, + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert!( + !chat.bottom_pane.has_active_view(), + "loaded workflow manager should replace loading view instead of stacking above it" + ); +} + +#[tokio::test] +async fn workflow_manager_redacts_sensitive_spec_id() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + let mut workflow = test_workflow("workflow-alpha", ThreadWorkflowStatus::Draft); + workflow.spec_workflow_id = "source_prompt secret workflow id".to_string(); + + chat.show_thread_workflow_manager( + thread_id, + ThreadWorkflowListResponse { + data: vec![workflow], + next_cursor: None, + }, + ThreadWorkflowRunListResponse { + data: Vec::new(), + next_cursor: None, + }, + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + let popup = render_bottom_popup(&chat, /*width*/ 120); + + assert!(popup.contains("[redacted]")); + assert!(!popup.contains("source_prompt secret workflow id")); +} + +#[tokio::test] +async fn workflow_manager_row_opens_actions() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.show_thread_workflow_manager( + thread_id, + ThreadWorkflowListResponse { + data: vec![test_workflow("workflow-alpha", ThreadWorkflowStatus::Draft)], + next_cursor: None, + }, + ThreadWorkflowRunListResponse { + data: Vec::new(), + next_cursor: None, + }, + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected workflow action event"); + let AppEvent::OpenThreadWorkflowActions { + thread_id: actual_thread_id, + workflow, + } = event + else { + panic!("expected OpenThreadWorkflowActions, got {event:?}"); + }; + assert_eq!(actual_thread_id, thread_id); + assert_eq!(workflow.workflow_record_id, "workflow-alpha"); +} + +#[tokio::test] +async fn workflow_manager_draft_prompt_is_blocked_while_task_runs() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); + + chat.show_thread_workflow_draft_prompt(); + + assert!(!chat.bottom_pane.has_active_view()); + while let Ok(event) = rx.try_recv() { + assert!( + !matches!(event, AppEvent::PrefillComposer { .. }), + "blocked draft prompt should not prefill the composer" + ); + } +} diff --git a/codex-rs/tui/src/chatwidget/usage_profile_broker.rs b/codex-rs/tui/src/chatwidget/usage_profile_broker.rs index f74b93597..5f7db40a9 100644 --- a/codex-rs/tui/src/chatwidget/usage_profile_broker.rs +++ b/codex-rs/tui/src/chatwidget/usage_profile_broker.rs @@ -49,6 +49,17 @@ pub(super) fn exhausted_auto_switch_window_for_snapshot( .map(tui_auto_switch_window) } +pub(super) fn earliest_exhausted_reset_at( + snapshot: &RateLimitSnapshot, + is_codex_limit: bool, + now_unix_secs: i64, +) -> Option { + usage_profile_health::earliest_exhausted_reset_at( + &app_server_rate_limit_snapshot(snapshot, is_codex_limit), + now_unix_secs, + ) +} + pub(super) fn auto_switch_trigger_key( limit_id: &str, window: &UsageProfileAutoSwitchWindow, @@ -68,6 +79,7 @@ pub(super) fn auth_profile_auto_switch_target( Option, BTreeMap, >, + recently_failed_profiles: &HashSet, limit_id: &str, window: &UsageProfileAutoSwitchWindow, ) -> Option { @@ -78,6 +90,7 @@ pub(super) fn auth_profile_auto_switch_target( | AuthProfileAutoSwitchStrategy::Ordered => healthiest_auth_profile_for_auto_switch( config, cached_snapshots_by_profile, + recently_failed_profiles, &candidates, window, ), @@ -143,6 +156,7 @@ fn healthiest_auth_profile_for_auto_switch( Option, BTreeMap, >, + recently_failed_profiles: &HashSet, candidates: &[String], window: &UsageProfileAutoSwitchWindow, ) -> Option { @@ -152,10 +166,18 @@ fn healthiest_auth_profile_for_auto_switch( for profile in candidates { let profile_key = Some(profile.clone()); let snapshots = cached_snapshots_by_profile.get(&profile_key); - health_by_profile.insert( - profile.clone(), - auth_profile_usage_health_for_auto_switch(snapshots, window, config, freshness), - ); + let mut health = + auth_profile_usage_health_for_auto_switch(snapshots, window, config, freshness); + // Do not optimistically switch onto a profile whose most recent usage heartbeat + // failed: we could not confirm it is available, so treat an otherwise-Unknown + // profile as unavailable rather than a switch target. The failure backoff clears + // this after a short window, so the profile is reconsidered once it recovers. + if recently_failed_profiles.contains(profile) + && matches!(health, UsageProfileHealth::Unknown) + { + health = UsageProfileHealth::Exhausted { retry_at: None }; + } + health_by_profile.insert(profile.clone(), health); } choose_profile_for_auto_switch(config, candidates, &health_by_profile).selected_profile diff --git a/codex-rs/tui/src/chatwidget/workflow_display.rs b/codex-rs/tui/src/chatwidget/workflow_display.rs index 75fef05cc..1d595953a 100644 --- a/codex-rs/tui/src/chatwidget/workflow_display.rs +++ b/codex-rs/tui/src/chatwidget/workflow_display.rs @@ -334,11 +334,11 @@ fn goal_plan_summary_line(goal_plan: &ThreadGoalPlan) -> Line<'static> { .into() } -fn public_workflow_display_name(value: &str) -> String { +pub(super) fn public_workflow_display_name(value: &str) -> String { sanitize_metadata_label_with_redaction(value, "[redacted workflow name]") } -fn sanitize_metadata_label(value: &str) -> String { +pub(super) fn sanitize_metadata_label(value: &str) -> String { sanitize_metadata_label_with_redaction(value, "[redacted]") } @@ -400,7 +400,7 @@ fn truncate_display_name(value: &str, max_chars: usize) -> String { truncated } -fn workflow_status_label(status: ThreadWorkflowStatus) -> &'static str { +pub(super) fn workflow_status_label(status: ThreadWorkflowStatus) -> &'static str { match status { ThreadWorkflowStatus::Draft => "draft", ThreadWorkflowStatus::NeedsClarification => "needs clarification", @@ -408,7 +408,7 @@ fn workflow_status_label(status: ThreadWorkflowStatus) -> &'static str { } } -fn workflow_run_status_label(status: ThreadWorkflowRunStatus) -> &'static str { +pub(super) fn workflow_run_status_label(status: ThreadWorkflowRunStatus) -> &'static str { match status { ThreadWorkflowRunStatus::Pending => "pending", ThreadWorkflowRunStatus::Running => "running", @@ -463,7 +463,7 @@ fn goal_plan_status_label(status: ThreadGoalPlanStatus) -> &'static str { } } -fn short_id(value: &str) -> &str { +pub(super) fn short_id(value: &str) -> &str { value .char_indices() .nth(12) diff --git a/codex-rs/tui/src/chatwidget/workflow_manager.rs b/codex-rs/tui/src/chatwidget/workflow_manager.rs new file mode 100644 index 000000000..154da5d71 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/workflow_manager.rs @@ -0,0 +1,692 @@ +//! Interactive workflow manager for `/workflow` and `/workflows`. + +use super::*; +use crate::app_event::ThreadWorkflowAction; +use crate::bottom_pane::SelectionTab; +use crate::chatwidget::workflow_display::public_workflow_display_name; +use crate::chatwidget::workflow_display::sanitize_metadata_label; +use crate::chatwidget::workflow_display::short_id; +use crate::chatwidget::workflow_display::workflow_run_status_label; +use crate::chatwidget::workflow_display::workflow_status_label; +use codex_app_server_protocol::ThreadWorkflow; +use codex_app_server_protocol::ThreadWorkflowListResponse; +use codex_app_server_protocol::ThreadWorkflowRun; +use codex_app_server_protocol::ThreadWorkflowRunListResponse; +use codex_app_server_protocol::ThreadWorkflowRunStatus; +use codex_app_server_protocol::ThreadWorkflowStatus; + +const SPECS_TAB_ID: &str = "specs"; +const RUNS_TAB_ID: &str = "runs"; +const APPROVALS_TAB_ID: &str = "approvals"; +const WORKFLOW_MANAGER_VIEW_ID: &str = "workflow-manager"; + +impl ChatWidget { + pub(crate) fn show_thread_workflow_manager_loading(&mut self, thread_id: ThreadId) { + self.replace_or_show_thread_workflow_manager(thread_workflow_manager_loading_params( + thread_id, + )); + } + + pub(crate) fn show_thread_workflow_manager_error( + &mut self, + thread_id: ThreadId, + action: &'static str, + err: &color_eyre::Report, + ) { + self.replace_or_show_thread_workflow_manager(thread_workflow_manager_error_params( + thread_id, + action, + &err.to_string(), + )); + } + + pub(crate) fn show_thread_workflow_manager( + &mut self, + thread_id: ThreadId, + workflows: ThreadWorkflowListResponse, + runs: ThreadWorkflowRunListResponse, + ) { + self.replace_or_show_thread_workflow_manager(thread_workflow_manager_params( + thread_id, + workflows.data, + workflows.next_cursor.is_some(), + runs.data, + runs.next_cursor.is_some(), + )); + } + + fn replace_or_show_thread_workflow_manager(&mut self, params: SelectionViewParams) { + if self.bottom_pane.active_view_id() == Some(WORKFLOW_MANAGER_VIEW_ID) { + let replaced = self + .bottom_pane + .replace_selection_view_if_active(WORKFLOW_MANAGER_VIEW_ID, params); + debug_assert!(replaced); + } else { + self.show_selection_view(params); + } + } + + pub(crate) fn show_thread_workflow_actions( + &mut self, + thread_id: ThreadId, + workflow: ThreadWorkflow, + ) { + self.show_selection_view(thread_workflow_actions_params(thread_id, workflow)); + } + + pub(crate) fn show_thread_workflow_run_actions( + &mut self, + thread_id: ThreadId, + run: ThreadWorkflowRun, + ) { + self.show_selection_view(thread_workflow_run_actions_params(thread_id, run)); + } + + pub(crate) fn show_thread_workflow_draft_prompt(&mut self) { + if self.bottom_pane.is_task_running() { + self.add_error_message( + "'/workflow draft' is disabled while a task is in progress.".to_string(), + ); + self.request_redraw(); + return; + } + + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Create workflow".to_string(), + "Describe the workflow to draft and press Enter".to_string(), + String::new(), + /*context_label*/ None, + Box::new(move |request: String| { + tx.send(AppEvent::PrefillComposer { + text: super::workflow_slash::workflow_generation_prompt(&request), + }); + }), + ); + self.bottom_pane.show_view(Box::new(view)); + } +} + +fn thread_workflow_manager_loading_params(thread_id: ThreadId) -> SelectionViewParams { + let refresh_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenThreadWorkflowManager { thread_id }); + })]; + SelectionViewParams { + view_id: Some(WORKFLOW_MANAGER_VIEW_ID), + title: Some("Workflows".to_string()), + subtitle: Some("Loading workflow specs and runs".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: "Loading workflows".to_string(), + description: Some("Fetching saved specs and recent runs for this thread".to_string()), + actions: refresh_actions, + dismiss_on_select: true, + ..Default::default() + }], + col_width_mode: ColumnWidthMode::Fixed, + ..Default::default() + } +} + +fn thread_workflow_manager_error_params( + thread_id: ThreadId, + action: &'static str, + err: &str, +) -> SelectionViewParams { + let refresh_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenThreadWorkflowManager { thread_id }); + })]; + SelectionViewParams { + view_id: Some(WORKFLOW_MANAGER_VIEW_ID), + title: Some("Workflows".to_string()), + subtitle: Some(format!("Failed to {action}")), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Retry".to_string(), + description: Some("Refresh workflow specs and runs".to_string()), + actions: refresh_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Workflow manager unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }, + ], + col_width_mode: ColumnWidthMode::Fixed, + ..Default::default() + } +} + +fn thread_workflow_manager_params( + thread_id: ThreadId, + mut workflows: Vec, + workflows_truncated: bool, + mut runs: Vec, + runs_truncated: bool, +) -> SelectionViewParams { + workflows.sort_by_key(|workflow| { + ( + workflow_status_sort_key(workflow.status), + std::cmp::Reverse(workflow.updated_at), + workflow.workflow_record_id.clone(), + ) + }); + runs.sort_by_key(|run| { + ( + workflow_run_status_sort_key(run.status), + std::cmp::Reverse(run.updated_at), + run.run_id.clone(), + ) + }); + + SelectionViewParams { + view_id: Some(WORKFLOW_MANAGER_VIEW_ID), + title: Some("Workflows".to_string()), + subtitle: Some(workflow_manager_subtitle(&workflows, &runs)), + footer_hint: Some(standard_popup_hint_line()), + items: Vec::new(), + tabs: vec![ + SelectionTab { + id: SPECS_TAB_ID.to_string(), + label: format!("Specs ({})", workflows.len()), + header: Box::new(()), + items: workflow_spec_items(thread_id, workflows, workflows_truncated), + }, + SelectionTab { + id: RUNS_TAB_ID.to_string(), + label: format!("Runs ({})", runs.len()), + header: Box::new(()), + items: workflow_run_items(thread_id, runs, runs_truncated), + }, + SelectionTab { + id: APPROVALS_TAB_ID.to_string(), + label: "Approvals".to_string(), + header: Box::new(()), + items: workflow_approval_items(), + }, + ], + initial_tab_id: Some(SPECS_TAB_ID.to_string()), + is_searchable: true, + search_placeholder: Some("Search workflows".to_string()), + col_width_mode: ColumnWidthMode::Fixed, + ..Default::default() + } +} + +fn workflow_spec_items( + thread_id: ThreadId, + workflows: Vec, + workflows_truncated: bool, +) -> Vec { + let create_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenThreadWorkflowDraftPrompt); + })]; + let mut items = vec![SelectionItem { + name: "Create from prompt".to_string(), + description: Some("Draft workflow YAML from a natural-language request".to_string()), + actions: create_actions, + dismiss_on_select: true, + search_value: Some("create draft prompt workflow yaml".to_string()), + ..Default::default() + }]; + + if workflows.is_empty() { + items.push(SelectionItem { + name: "No saved workflow specs".to_string(), + description: Some( + "Create one from a prompt or use /workflow draft ".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + for workflow in workflows { + let workflow_for_action = workflow.clone(); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenThreadWorkflowActions { + thread_id, + workflow: workflow_for_action.clone(), + }); + })]; + items.push(SelectionItem { + name: workflow_row_name(&workflow), + selected_description: Some(workflow_selected_detail(&workflow)), + actions, + dismiss_on_select: true, + search_value: Some(workflow_search_value(&workflow)), + ..Default::default() + }); + } + } + + if workflows_truncated { + items.push(SelectionItem { + name: "More workflow specs available".to_string(), + description: Some("Only the first page is shown here".to_string()), + is_disabled: true, + ..Default::default() + }); + } + + items +} + +fn workflow_run_items( + thread_id: ThreadId, + runs: Vec, + runs_truncated: bool, +) -> Vec { + let mut items = Vec::new(); + if runs.is_empty() { + items.push(SelectionItem { + name: "No workflow runs".to_string(), + description: Some("Start a run from a saved spec".to_string()), + is_disabled: true, + ..Default::default() + }); + } else { + for run in runs { + let run_for_action = run.clone(); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenThreadWorkflowRunActions { + thread_id, + run: run_for_action.clone(), + }); + })]; + items.push(SelectionItem { + name: workflow_run_row_name(&run), + selected_description: Some(workflow_run_selected_detail(&run)), + actions, + dismiss_on_select: true, + search_value: Some(workflow_run_search_value(&run)), + ..Default::default() + }); + } + } + + if runs_truncated { + items.push(SelectionItem { + name: "More workflow runs available".to_string(), + description: Some("Only the first page is shown here".to_string()), + is_disabled: true, + ..Default::default() + }); + } + + items +} + +fn workflow_approval_items() -> Vec { + vec![SelectionItem { + name: "Workflow approval review".to_string(), + description: Some( + "Uses the existing approval popups; workflow-specific review queue is not exposed yet" + .to_string(), + ), + is_disabled: true, + disabled_reason: Some( + "No workflow-scoped approval review API is available in this branch".to_string(), + ), + search_value: Some("approval review guardian workflow".to_string()), + ..Default::default() + }] +} + +fn thread_workflow_actions_params( + thread_id: ThreadId, + workflow: ThreadWorkflow, +) -> SelectionViewParams { + let inspect_workflow_id = workflow.workflow_record_id.clone(); + let run_workflow_id = workflow.workflow_record_id.clone(); + let mut items = vec![ + workflow_action_item( + "Inspect spec", + "Show sanitized spec metadata", + /*is_disabled*/ false, + /*disabled_reason*/ None, + move || AppEvent::ManageThreadWorkflow { + thread_id, + action: ThreadWorkflowAction::Show { + workflow_record_id: inspect_workflow_id.clone(), + }, + }, + ), + workflow_action_item( + "Run", + "Start a new workflow run", + /*is_disabled*/ false, + /*disabled_reason*/ None, + move || AppEvent::ManageThreadWorkflow { + thread_id, + action: ThreadWorkflowAction::RunStart { + workflow_record_id: run_workflow_id.clone(), + }, + }, + ), + disabled_workflow_item( + "Delete", + "Workflow delete is not available in the current API", + "Missing workflow delete RPC", + ), + disabled_workflow_item( + "Review approvals", + "Workflow-specific approval review is not available yet", + "Approval review uses the existing global approval popups", + ), + ]; + let back_thread_id = thread_id; + items.push(workflow_action_item( + "Back to workflows", + "Return to specs and runs", + /*is_disabled*/ false, + /*disabled_reason*/ None, + move || AppEvent::OpenThreadWorkflowManager { + thread_id: back_thread_id, + }, + )); + + SelectionViewParams { + title: Some(public_workflow_display_name(&workflow.display_name)), + subtitle: Some(workflow_selected_detail(&workflow)), + footer_hint: Some(standard_popup_hint_line()), + items, + col_width_mode: ColumnWidthMode::Fixed, + ..Default::default() + } +} + +fn thread_workflow_run_actions_params( + thread_id: ThreadId, + run: ThreadWorkflowRun, +) -> SelectionViewParams { + let inspect_run_id = run.run_id.clone(); + let mut items = vec![workflow_action_item( + "Inspect run", + "Show step, verifier, and event progress", + /*is_disabled*/ false, + /*disabled_reason*/ None, + move || AppEvent::ManageThreadWorkflow { + thread_id, + action: ThreadWorkflowAction::RunShow { + run_id: inspect_run_id.clone(), + }, + }, + )]; + + if workflow_run_can_pause(run.status) { + let pause_run_id = run.run_id.clone(); + items.push(workflow_action_item( + "Pause", + "Pause this workflow run", + /*is_disabled*/ false, + /*disabled_reason*/ None, + move || AppEvent::ManageThreadWorkflow { + thread_id, + action: ThreadWorkflowAction::RunPause { + run_id: pause_run_id.clone(), + }, + }, + )); + } + if workflow_run_can_resume(run.status) { + let resume_run_id = run.run_id.clone(); + items.push(workflow_action_item( + "Resume", + "Resume this workflow run", + /*is_disabled*/ false, + /*disabled_reason*/ None, + move || AppEvent::ManageThreadWorkflow { + thread_id, + action: ThreadWorkflowAction::RunResume { + run_id: resume_run_id.clone(), + }, + }, + )); + } + let cancel_run_id = run.run_id.clone(); + let cannot_stop = !workflow_run_can_cancel(run.status); + items.push(workflow_action_item( + "Stop", + "Request cancellation for this workflow run", + cannot_stop, + cannot_stop.then(|| "Completed workflow runs cannot be stopped".to_string()), + move || AppEvent::ManageThreadWorkflow { + thread_id, + action: ThreadWorkflowAction::RunCancel { + run_id: cancel_run_id.clone(), + }, + }, + )); + items.push(disabled_workflow_item( + "Review approvals", + "Workflow-specific approval review is not available yet", + "Approval review uses the existing global approval popups", + )); + items.push(workflow_action_item( + "Back to workflows", + "Return to specs and runs", + /*is_disabled*/ false, + /*disabled_reason*/ None, + move || AppEvent::OpenThreadWorkflowManager { thread_id }, + )); + + SelectionViewParams { + title: Some(format!("Run {}", short_id(&run.run_id))), + subtitle: Some(workflow_run_selected_detail(&run)), + footer_hint: Some(standard_popup_hint_line()), + items, + col_width_mode: ColumnWidthMode::Fixed, + ..Default::default() + } +} + +fn workflow_action_item( + name: &'static str, + description: impl Into, + is_disabled: bool, + disabled_reason: Option, + event: impl Fn() -> AppEvent + Send + Sync + 'static, +) -> SelectionItem { + let actions: Vec = vec![Box::new(move |tx| tx.send(event()))]; + SelectionItem { + name: name.to_string(), + description: Some(description.into()), + is_disabled, + disabled_reason, + actions, + dismiss_on_select: true, + ..Default::default() + } +} + +fn disabled_workflow_item( + name: &'static str, + description: &'static str, + disabled_reason: &'static str, +) -> SelectionItem { + SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + is_disabled: true, + disabled_reason: Some(disabled_reason.to_string()), + ..Default::default() + } +} + +fn workflow_manager_subtitle(workflows: &[ThreadWorkflow], runs: &[ThreadWorkflowRun]) -> String { + let active_runs = runs + .iter() + .filter(|run| workflow_run_is_active(run.status)) + .count(); + let completed_runs = runs + .iter() + .filter(|run| run.status == ThreadWorkflowRunStatus::Completed) + .count(); + middle_dot(vec![ + format!("{} saved specs", workflows.len()), + format!("{active_runs} active runs"), + format!("{completed_runs} completed runs"), + ]) +} + +fn middle_dot(parts: Vec) -> String { + parts + .into_iter() + .filter(|part| !part.trim().is_empty()) + .collect::>() + .join(" · ") +} + +fn workflow_row_name(workflow: &ThreadWorkflow) -> String { + middle_dot(vec![ + public_workflow_display_name(&workflow.display_name), + workflow_status_label(workflow.status).to_string(), + format!("{} steps", workflow.step_count), + format!("{} agents", workflow.agent_count), + ]) +} + +fn workflow_selected_detail(workflow: &ThreadWorkflow) -> String { + middle_dot(vec![ + sanitize_metadata_label(&workflow.spec_workflow_id), + format!("{} verifiers", workflow.verifier_count), + format!("{} model-routed steps", workflow.model_routed_step_count), + format!("record {}", short_id(&workflow.workflow_record_id)), + format!("updated {}", workflow.updated_at), + ]) +} + +fn workflow_run_row_name(run: &ThreadWorkflowRun) -> String { + middle_dot(vec![ + sanitize_metadata_label(&run.spec_workflow_id), + workflow_run_status_label(run.status).to_string(), + workflow_run_progress_label(run), + format!("run {}", short_id(&run.run_id)), + ]) +} + +fn workflow_run_selected_detail(run: &ThreadWorkflowRun) -> String { + let mut parts = vec![ + workflow_run_activity_label(run), + format!("{} verifiers", run.verifier_count), + format!("{} events", run.event_count), + format!("workflow {}", short_id(&run.workflow_record_id)), + ]; + if let Some(reason) = run + .status_reason + .as_ref() + .filter(|reason| !reason.is_empty()) + { + parts.push(sanitize_metadata_label(reason)); + } + middle_dot(parts) +} + +fn workflow_run_progress_label(run: &ThreadWorkflowRun) -> String { + format!( + "{} active, {} waiting, {} done, {} failed", + run.active_step_count, + run.waiting_verifier_step_count, + run.succeeded_step_count, + run.failed_step_count + ) +} + +fn workflow_run_activity_label(run: &ThreadWorkflowRun) -> String { + let mut parts = Vec::new(); + if run.active_step_count > 0 { + parts.push(format!("{} agent steps active", run.active_step_count)); + } + if run.waiting_verifier_step_count > 0 { + parts.push(format!( + "{} steps waiting for verifiers", + run.waiting_verifier_step_count + )); + } + if run.event_count > 0 { + parts.push(format!("{} monitor/activity events", run.event_count)); + } + if parts.is_empty() { + "no live agent or monitor activity".to_string() + } else { + parts.join(", ") + } +} + +fn workflow_search_value(workflow: &ThreadWorkflow) -> String { + format!( + "{} {} {} {}", + workflow.workflow_record_id, + workflow.spec_workflow_id, + workflow.display_name, + workflow.source_yaml_sha256 + ) +} + +fn workflow_run_search_value(run: &ThreadWorkflowRun) -> String { + format!( + "{} {} {} {} {:?}", + run.run_id, + run.workflow_record_id, + run.spec_workflow_id, + run.source_yaml_sha256, + run.status + ) +} + +fn workflow_status_sort_key(status: ThreadWorkflowStatus) -> u8 { + match status { + ThreadWorkflowStatus::Draft => 0, + ThreadWorkflowStatus::NeedsClarification => 1, + ThreadWorkflowStatus::Blocked => 2, + } +} + +fn workflow_run_status_sort_key(status: ThreadWorkflowRunStatus) -> u8 { + match status { + ThreadWorkflowRunStatus::Running + | ThreadWorkflowRunStatus::Waiting + | ThreadWorkflowRunStatus::Blocked + | ThreadWorkflowRunStatus::Paused + | ThreadWorkflowRunStatus::CancelRequested => 0, + ThreadWorkflowRunStatus::Pending => 1, + ThreadWorkflowRunStatus::Failed => 2, + ThreadWorkflowRunStatus::Completed + | ThreadWorkflowRunStatus::Cancelled + | ThreadWorkflowRunStatus::Other => 3, + } +} + +fn workflow_run_is_active(status: ThreadWorkflowRunStatus) -> bool { + matches!( + status, + ThreadWorkflowRunStatus::Pending + | ThreadWorkflowRunStatus::Running + | ThreadWorkflowRunStatus::Waiting + | ThreadWorkflowRunStatus::Blocked + | ThreadWorkflowRunStatus::Paused + | ThreadWorkflowRunStatus::CancelRequested + ) +} + +fn workflow_run_can_pause(status: ThreadWorkflowRunStatus) -> bool { + matches!( + status, + ThreadWorkflowRunStatus::Pending + | ThreadWorkflowRunStatus::Running + | ThreadWorkflowRunStatus::Waiting + | ThreadWorkflowRunStatus::Blocked + ) +} + +fn workflow_run_can_resume(status: ThreadWorkflowRunStatus) -> bool { + matches!(status, ThreadWorkflowRunStatus::Paused) +} + +fn workflow_run_can_cancel(status: ThreadWorkflowRunStatus) -> bool { + workflow_run_is_active(status) +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5dc5c7cff..2dec9d8bc 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -59,6 +59,7 @@ pub enum SlashCommand { serialize = "missions" )] MissionControl, + #[strum(to_string = "workflow", serialize = "workflows")] Workflow, Loop, Queued, @@ -826,6 +827,10 @@ mod tests { #[test] fn workflow_command_is_singular_and_accepts_args() { assert_eq!(SlashCommand::Workflow.command(), "workflow"); + assert_eq!( + SlashCommand::from_str("workflows"), + Ok(SlashCommand::Workflow) + ); assert_eq!( SlashCommand::Workflow.description(), "manage workflow specs and runs for this thread" From 10669682d3d3cbe4cfc71de26d06843b62852079 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 9 Jul 2026 11:24:16 +0300 Subject: [PATCH 2/3] ci: skip oversized TUI unit bin on Windows clippy --- scripts/list-bazel-clippy-targets.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/list-bazel-clippy-targets.sh b/scripts/list-bazel-clippy-targets.sh index d12a256d0..40ade2ffb 100755 --- a/scripts/list-bazel-clippy-targets.sh +++ b/scripts/list-bazel-clippy-targets.sh @@ -39,6 +39,11 @@ elif [[ $windows_cross_compile -eq 1 ]]; then # the Windows helpers and unit-test helpers, but do not pass the native-only # sharded integration helpers as explicit clippy targets. manual_rust_test_targets="$(printf '%s\n' "${manual_rust_test_targets}" | grep -v -- '-test-bin$' || true)" + # The TUI unit-test binary is a very large all-in-one test harness. Hosted + # Windows rustc/clippy has terminated it with STATUS_STACK_BUFFER_OVERRUN + # before emitting a Rust diagnostic, while the library target and non-Windows + # unit-test clippy continue to cover the code. + manual_rust_test_targets="$(printf '%s\n' "${manual_rust_test_targets}" | grep -v -x -- '//codex-rs/tui:tui-unit-tests-bin' || true)" fi printf '%s\n' \ From 91292d2ce438b1cf8dcd2d5be1dbaf0f2e158395 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 9 Jul 2026 11:34:34 +0300 Subject: [PATCH 3/3] ci: document Windows TUI clippy coverage tradeoff --- scripts/list-bazel-clippy-targets.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/list-bazel-clippy-targets.sh b/scripts/list-bazel-clippy-targets.sh index 40ade2ffb..345edffa0 100755 --- a/scripts/list-bazel-clippy-targets.sh +++ b/scripts/list-bazel-clippy-targets.sh @@ -42,7 +42,9 @@ elif [[ $windows_cross_compile -eq 1 ]]; then # The TUI unit-test binary is a very large all-in-one test harness. Hosted # Windows rustc/clippy has terminated it with STATUS_STACK_BUFFER_OVERRUN # before emitting a Rust diagnostic, while the library target and non-Windows - # unit-test clippy continue to cover the code. + # unit-test clippy continue to cover the code. This intentionally gives up + # Windows-only cfg(test) clippy coverage until the TUI unit-test target is + # split or the upstream Windows compiler crash is no longer reproducible. manual_rust_test_targets="$(printf '%s\n' "${manual_rust_test_targets}" | grep -v -x -- '//codex-rs/tui:tui-unit-tests-bin' || true)" fi