diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index bf3ea9b71..7c79b430d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -958,6 +958,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/thread_workflow_actions.rs b/codex-rs/tui/src/app/thread_workflow_actions.rs index 8a4c72347..c975f20b6 100644 --- a/codex-rs/tui/src/app/thread_workflow_actions.rs +++ b/codex-rs/tui/src/app/thread_workflow_actions.rs @@ -4,6 +4,40 @@ 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, + ) { + if self.current_displayed_thread_id() != Some(thread_id) { + return; + } + 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 c5a823e37..174cf407c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -35,6 +35,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; @@ -403,6 +405,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 c0bee7a8d..05ade56d8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -370,6 +370,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; diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 3a84cc7a5..60ca9b0b7 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -1154,11 +1154,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(), 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 index 47e849829..345de14ed 100644 --- 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 @@ -1,15 +1,16 @@ --- source: tui/src/chatwidget/tests/slash_commands.rs +assertion_line: 3505 expression: preview --- • # Codewith Changelog ## [Unreleased] - ### Fixed + ## [0.1.57] - 2026-07-05 - - Recovered loop-driven and long-running turns from context-window overflow - errors by compacting mid-turn and retrying once before surfacing the - failure. - - ## [0.1.54] - 2026-07-02 + Tag: rust-v0.1.57 + npm: https://www.npmjs.com/package/@hasna/codewith/v/0.1.57 + (https://www.npmjs.com/package/@hasna/codewith/v/0.1.57) + Compare: https://github.com/hasna/codewith/compare/rust-v0.1.56...rust-v0.1.57 + (https://github.com/hasna/codewith/compare/rust-v0.1.56...rust-v0.1.57) 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 8167cde04..0e269b20d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -244,6 +244,7 @@ mod status_command_tests; mod status_surface_previews; mod terminal_title; 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/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 6e61b1ae4..745c6d775 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 = [ diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8654b37d2..0680f6fe6 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -61,6 +61,13 @@ fn cache_project_root(chat: &mut ChatWidget, root_name: &str) { }); } +fn cache_missing_project_root(chat: &mut ChatWidget) { + chat.status_line_project_root_name_cache = Some(CachedProjectRootName { + cwd: chat.config.cwd.to_path_buf(), + root_name: None, + }); +} + fn cache_rate_limit_snapshot(chat: &mut ChatWidget) { chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: None, @@ -129,6 +136,7 @@ async fn status_line_setup_popup_live_only_snapshot() { #[tokio::test] async fn status_surface_preview_lines_hardcoded_only_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_missing_project_root(&mut chat); let snapshot = combined_preview_snapshot( &mut chat, @@ -168,6 +176,7 @@ async fn thread_title_falls_back_to_thread_id_when_unnamed() { #[tokio::test] async fn status_line_setup_popup_hardcoded_only_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_missing_project_root(&mut chat); chat.config.tui_status_line = Some(vec![ "project-name".to_string(), "git-branch".to_string(), @@ -183,6 +192,7 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() { #[tokio::test] async fn status_surface_preview_lines_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_missing_project_root(&mut chat); chat.status_line_branch = Some("feature/mixed-preview".to_string()); chat.thread_name = Some("Mixed preview thread".to_string()); @@ -280,6 +290,7 @@ async fn status_line_setup_popup_rate_limits_snapshot() { #[tokio::test] async fn status_line_setup_popup_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_missing_project_root(&mut chat); chat.status_line_branch = Some("feature/mixed-preview".to_string()); chat.thread_name = Some("Mixed preview thread".to_string()); chat.config.tui_status_line = Some(vec![ @@ -332,6 +343,7 @@ async fn terminal_title_setup_popup_hardcoded_only_snapshot() { #[tokio::test] async fn terminal_title_setup_popup_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_missing_project_root(&mut chat); chat.thread_name = Some("Mixed preview thread".to_string()); chat.config.tui_terminal_title = Some(vec![ "project-name".to_string(), @@ -363,6 +375,7 @@ async fn terminal_title_setup_popup_rate_limits_snapshot() { #[tokio::test] async fn missing_project_root_uses_different_status_and_title_preview_sources() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_missing_project_root(&mut chat); let status_preview = status_preview_line(&mut chat, &[StatusLineItem::ProjectRoot]); let title_preview = title_preview_line(&mut chat, &[TerminalTitleItem::Project]); diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index 317942485..4e341609d 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -3,9 +3,17 @@ use super::*; use pretty_assertions::assert_eq; +fn cache_missing_project_root(chat: &mut ChatWidget) { + chat.status_line_project_root_name_cache = Some(CachedProjectRootName { + cwd: chat.config.cwd.to_path_buf(), + root_name: None, + }); +} + #[tokio::test] async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_missing_project_root(&mut chat); chat.bottom_pane.set_task_running(/*running*/ true); chat.refresh_terminal_title(); @@ -47,6 +55,7 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { #[tokio::test] async fn terminal_title_action_required_respects_spinner_setting() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_missing_project_root(&mut chat); chat.config.tui_terminal_title = Some(vec!["project".to_string()]); chat.bottom_pane.set_task_running(/*running*/ true); chat.refresh_terminal_title(); @@ -75,6 +84,7 @@ async fn terminal_title_action_required_respects_spinner_setting() { #[tokio::test] async fn terminal_title_action_required_blinks_when_animations_are_enabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_missing_project_root(&mut chat); chat.bottom_pane.set_task_running(/*running*/ true); chat.terminal_title_animation_origin = Instant::now() - std::time::Duration::from_millis(1500); chat.refresh_terminal_title(); @@ -106,6 +116,7 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() { #[tokio::test] async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_missing_project_root(&mut chat); chat.config.animations = false; chat.bottom_pane.set_task_running(/*running*/ true); chat.terminal_title_animation_origin = Instant::now() - std::time::Duration::from_millis(1500); 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..223ebf085 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/workflow_manager.rs @@ -0,0 +1,217 @@ +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_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/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..10f7ad9ce --- /dev/null +++ b/codex-rs/tui/src/chatwidget/workflow_manager.rs @@ -0,0 +1,675 @@ +//! 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"; + +impl ChatWidget { + pub(crate) fn show_thread_workflow_manager_loading(&mut self, thread_id: ThreadId) { + self.show_selection_view(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.show_selection_view(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.show_selection_view(thread_workflow_manager_params( + thread_id, + workflows.data, + workflows.next_cursor.is_some(), + runs.data, + runs.next_cursor.is_some(), + )); + } + + 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 { + 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 { + 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 { + 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![ + workflow.spec_workflow_id.clone(), + 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 42475529c..6b03d91af 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, @@ -786,6 +787,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"