| 1 | //! `/workflow` command — the user's opt-in to workflow orchestration. |
| 2 | //! |
| 3 | //! The invocation carries authorization, not payload: bare `/workflow` asks |
| 4 | //! the model to synthesize the objective from the conversation context and |
| 5 | //! orchestrate it through the `workflow` tool (the same contract as goal-mode |
| 6 | //! `/goal`: context-dependent, no argument required). `/workflow <objective>` |
| 7 | //! narrows the run to an explicit objective, and `/workflow status` relays |
| 8 | //! typed run receipts without starting anything new. |
| 9 | |
| 10 | use crate::commands::traits::{CommandInfo, RegisterCommand}; |
| 11 | use crate::localization::MessageId; |
| 12 | use crate::tui::app::{App, AppAction}; |
| 13 | |
| 14 | use super::CommandResult; |
| 15 | |
| 16 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 17 | name: "workflow", |
| 18 | aliases: &["workflows", "wf"], |
| 19 | usage: "/workflow [objective|status|cancel <run_id>]", |
| 20 | description_id: MessageId::CmdWorkflowDescription, |
| 21 | }; |
| 22 | |
| 23 | pub(in crate::commands) struct WorkflowCmd; |
| 24 | |
| 25 | impl RegisterCommand for WorkflowCmd { |
| 26 | fn info() -> &'static CommandInfo { |
| 27 | &COMMAND_INFO |
| 28 | } |
| 29 | |
| 30 | fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 31 | workflow(app, arg) |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /// Shared orchestration contract appended to every start instruction. Mirrors |
| 36 | /// what makes opt-in orchestration work well: the user's invocation is the |
| 37 | /// authorization, fan-out scales to the ask, and receipts close the loop. |
| 38 | const ORCHESTRATION_CONTRACT: &str = "Author a workflow script for the `workflow` tool (task()/parallel()/pipeline()/phase()/log()); \ |
| 39 | you are the fan-in owner — fan out, wait for receipts, aggregate, verify, and synthesize one result. \ |
| 40 | scale the fan-out to the size of the ask — a quick check gets a few tasks, an audit gets a wider sweep. \ |
| 41 | Prefer pipeline() over barriers so items flow stage-to-stage without waiting. \ |
| 42 | Use responseSchema on task() when you need structured child output; schema mismatches fail loudly in the run receipt. \ |
| 43 | parallel() turns child failures into null — filter those slots and treat them as failures, not results. \ |
| 44 | Run it with the `workflow` tool (`run` to block, or `start` then `status` for long runs), \ |
| 45 | narrate phases as they complete, verify findings before reporting them as facts, \ |
| 46 | and end with a compact receipt summary: run_id, status, and per-leaf outcomes."; |
| 47 | |
| 48 | pub fn workflow(_app: &mut App, arg: Option<&str>) -> CommandResult { |
| 49 | let arg = arg.map(str::trim).filter(|value| !value.is_empty()); |
| 50 | |
| 51 | if let Some(action) = parse_workflow_control_action(arg) { |
| 52 | return action; |
| 53 | } |
| 54 | |
| 55 | match arg { |
| 56 | // Explicit objective: the argument narrows the run. |
| 57 | Some(objective) => { |
| 58 | let message = format!( |
| 59 | "The user invoked /workflow with an explicit objective — this is authorization to \ |
| 60 | orchestrate it with the `workflow` tool. Objective: {objective:?}. \ |
| 61 | Use the conversation context to ground the work (files discussed, prior findings). \ |
| 62 | {ORCHESTRATION_CONTRACT}" |
| 63 | ); |
| 64 | CommandResult::with_message_and_action( |
| 65 | format!("Orchestrating as a workflow: {objective}"), |
| 66 | AppAction::SendMessage(message), |
| 67 | ) |
| 68 | } |
| 69 | // Bare invocation: context-dependent. The model derives the objective |
| 70 | // from what the session is already doing — no restating required. |
| 71 | None => { |
| 72 | let message = format!( |
| 73 | "The user invoked /workflow with no argument — this is authorization to orchestrate \ |
| 74 | the CURRENT work as a workflow. Synthesize the objective from the conversation \ |
| 75 | context: the task in flight, recent findings, and open items. Do not ask the user \ |
| 76 | to restate it unless the conversation genuinely contains no work yet. \ |
| 77 | {ORCHESTRATION_CONTRACT}" |
| 78 | ); |
| 79 | CommandResult::with_message_and_action( |
| 80 | "Orchestrating the current work as a workflow...", |
| 81 | AppAction::SendMessage(message), |
| 82 | ) |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | /// Route `status`/`cancel` through the `workflow` tool without starting a run. |
| 88 | fn parse_workflow_control_action(arg: Option<&str>) -> Option<CommandResult> { |
| 89 | let arg = arg?; |
| 90 | let (verb, rest) = match arg.split_once(char::is_whitespace) { |
| 91 | Some((verb, rest)) => (verb, rest.trim()), |
| 92 | None => (arg, ""), |
| 93 | }; |
| 94 | match verb { |
| 95 | "status" | "runs" | "list" | "inspect" => { |
| 96 | let target = if rest.is_empty() { |
| 97 | "all runs".to_string() |
| 98 | } else { |
| 99 | format!("run_id `{rest}`") |
| 100 | }; |
| 101 | let message = format!( |
| 102 | "Call the `workflow` tool with action `status`{} and summarize the receipts for \ |
| 103 | the user: run_id, status, phase progress, per-leaf outcomes, and any errors. \ |
| 104 | Keep it compact. Do not start a new workflow.", |
| 105 | if rest.is_empty() { |
| 106 | String::new() |
| 107 | } else { |
| 108 | format!(" and run_id `{rest}`") |
| 109 | } |
| 110 | ); |
| 111 | Some(CommandResult::with_message_and_action( |
| 112 | format!("Fetching workflow status for {target}..."), |
| 113 | AppAction::SendMessage(message), |
| 114 | )) |
| 115 | } |
| 116 | "cancel" | "stop" | "abort" => { |
| 117 | if rest.is_empty() || rest.contains(char::is_whitespace) { |
| 118 | return Some(CommandResult::error( |
| 119 | "Usage: /workflow cancel <run_id>\n\nUse /workflow status to list run ids.", |
| 120 | )); |
| 121 | } |
| 122 | let message = format!( |
| 123 | "Call the `workflow` tool with action `cancel` and run_id `{rest}`, then report \ |
| 124 | the final run status to the user. Do not start a new workflow." |
| 125 | ); |
| 126 | Some(CommandResult::with_message_and_action( |
| 127 | format!("Cancelling workflow {rest}..."), |
| 128 | AppAction::SendMessage(message), |
| 129 | )) |
| 130 | } |
| 131 | _ => None, |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | #[cfg(test)] |
| 136 | mod tests { |
| 137 | use super::*; |
| 138 | use std::path::PathBuf; |
| 139 | |
| 140 | use crate::tui::app::TuiOptions; |
| 141 | |
| 142 | fn test_app() -> App { |
| 143 | let options = TuiOptions { |
| 144 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 145 | }; |
| 146 | App::new(options, &crate::config::Config::default()) |
| 147 | } |
| 148 | |
| 149 | #[test] |
| 150 | fn bare_workflow_is_context_dependent_opt_in() { |
| 151 | let mut app = test_app(); |
| 152 | let result = workflow(&mut app, None); |
| 153 | assert!(!result.is_error); |
| 154 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 155 | panic!("expected SendMessage action"); |
| 156 | }; |
| 157 | // The bare form must not demand an objective from the user. |
| 158 | assert!(message.contains("Synthesize the objective from the conversation")); |
| 159 | assert!(message.contains("authorization to orchestrate")); |
| 160 | assert!(message.contains("`workflow` tool")); |
| 161 | |
| 162 | // Whitespace-only behaves like bare. |
| 163 | let result = workflow(&mut app, Some(" ")); |
| 164 | assert!(matches!(result.action, Some(AppAction::SendMessage(_)))); |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn workflow_with_objective_forwards_it() { |
| 169 | let mut app = test_app(); |
| 170 | let result = workflow(&mut app, Some("audit provider error handling")); |
| 171 | assert!(!result.is_error); |
| 172 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 173 | panic!("expected SendMessage action"); |
| 174 | }; |
| 175 | assert!(message.contains("audit provider error handling")); |
| 176 | assert!(message.contains("authorization")); |
| 177 | } |
| 178 | |
| 179 | #[test] |
| 180 | fn workflow_status_and_cancel_route_to_tool_without_new_runs() { |
| 181 | let mut app = test_app(); |
| 182 | let result = workflow(&mut app, Some("status")); |
| 183 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 184 | panic!("expected SendMessage action"); |
| 185 | }; |
| 186 | assert!(message.contains("action `status`")); |
| 187 | assert!(message.contains("Do not start a new workflow")); |
| 188 | |
| 189 | let result = workflow(&mut app, Some("status wf_run_1")); |
| 190 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 191 | panic!("expected SendMessage action"); |
| 192 | }; |
| 193 | assert!(message.contains("run_id `wf_run_1`")); |
| 194 | |
| 195 | let result = workflow(&mut app, Some("cancel wf_run_1")); |
| 196 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 197 | panic!("expected SendMessage action"); |
| 198 | }; |
| 199 | assert!(message.contains("action `cancel`")); |
| 200 | assert!(message.contains("run_id `wf_run_1`")); |
| 201 | |
| 202 | let result = workflow(&mut app, Some("cancel")); |
| 203 | assert!(result.is_error, "cancel without a run id is a usage error"); |
| 204 | } |
| 205 | } |
| 206 |