返回 CodeWhale
workflow.rs
根目录 / crates / tui / src / commands / groups / core / workflow.rs
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
206 lines RUST