返回 CodeWhale
workflow.rs
根目录 / crates / tui / src / commands / groups / core / workflow.rs
1 //! `/workflow` command — review, confirm, then orchestrate.
2 //!
3 //! Ordinary objectives produce a bounded, tool-less planning turn. The user
4 //! reviews that draft and explicitly runs `/workflow confirm`; only that later
5 //! turn can reach the canonical `workflow` tool. Control verbs (`status`,
6 //! `cancel`, `settings`, `help`) remain host-owned and spend no model turn.
7 //!
8 //! `/workflows` (separate command, below) is the observation surface: the
9 //! live run dashboard. It never orchestrates — that authority belongs to
10 //! `/workflow` alone.
11
12 use crate::commands::traits::{CommandInfo, RegisterCommand};
13 use crate::tui::app::WORKFLOW_DRAFT_INSTRUCTION_PREFIX;
14 use crate::tui::app::{App, AppAction};
15 use codewhale_config::AppMode;
16 #[cfg(test)]
17 use codewhale_execpolicy::ApprovalMode;
18 use codewhale_localization::MessageId;
19 use codewhale_models::ContentBlock;
20
21 use super::CommandResult;
22
23 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
24 name: "workflow",
25 aliases: &["wf"],
26 usage: "/workflow [objective|confirm|run <path>|status [run_id]|cancel [run_id]|settings]",
27 description_id: MessageId::CmdWorkflowDescription,
28 };
29
30 pub(in crate::commands) struct WorkflowCmd;
31
32 impl RegisterCommand for WorkflowCmd {
33 fn info() -> &'static CommandInfo {
34 &COMMAND_INFO
35 }
36
37 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
38 workflow(app, arg)
39 }
40 }
41
42 const WORKFLOW_CONFIRM_INSTRUCTION_PREFIX: &str = "[codewhale.workflow-confirm.v1]";
43 const WORKFLOW_OBJECTIVE_MAX_CHARS: usize = 1_000;
44 const WORKFLOW_DISPLAY_MAX_CHARS: usize = 160;
45
46 #[derive(serde::Serialize, serde::Deserialize)]
47 struct WorkflowDraftEnvelope {
48 id: String,
49 objective: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 source_path: Option<String>,
52 }
53
54 fn truncate_workflow_text(text: &str, max_chars: usize) -> String {
55 if text.chars().count() <= max_chars {
56 return text.to_string();
57 }
58
59 let mut truncated: String = text.chars().take(max_chars.saturating_sub(1)).collect();
60 truncated.push('…');
61 truncated
62 }
63
64 fn workflow_display(prefix: &str, objective: Option<&str>) -> String {
65 let display = objective.map_or_else(
66 || format!("{prefix}current work"),
67 |objective| {
68 // Transcript rows are single-line summaries. The full (separately
69 // bounded) objective remains in the typed envelope reviewed by the
70 // model and later used by confirmation.
71 let objective = objective.split_whitespace().collect::<Vec<_>>().join(" ");
72 format!("{prefix}{objective}")
73 },
74 );
75 truncate_workflow_text(&display, WORKFLOW_DISPLAY_MAX_CHARS)
76 }
77
78 fn workflow_draft_instruction(id: &str, objective: Option<&str>) -> String {
79 let envelope = WorkflowDraftEnvelope {
80 id: id.to_string(),
81 objective: objective.map(ToOwned::to_owned),
82 source_path: None,
83 };
84 let encoded =
85 serde_json::to_string(&envelope).expect("workflow draft envelope is serializable");
86 format!(
87 "{WORKFLOW_DRAFT_INSTRUCTION_PREFIX}{encoded}\n\
88 Draft a short, plain-language Workflow proposal for review. Include the objective, 1–4 \
89 phases, estimated workers, and material risks. Do not call tools or execute work. End by \
90 asking the user to run `/workflow confirm` to start or revise the objective instead."
91 )
92 }
93
94 fn workflow_source_draft_instruction(id: &str, source_path: &str) -> String {
95 let envelope = WorkflowDraftEnvelope {
96 id: id.to_string(),
97 objective: None,
98 source_path: Some(source_path.to_string()),
99 };
100 let encoded = serde_json::to_string(&envelope).expect("workflow source draft is serializable");
101 format!(
102 "{WORKFLOW_DRAFT_INSTRUCTION_PREFIX}{encoded}\n\
103 Review the request to run this checked-in Workflow source. State the exact path, explain \
104 that its saved definition will run as-is, identify material risks, and ask the user to \
105 run `/workflow confirm` to start it. Do not call tools, inspect files, or execute work."
106 )
107 }
108
109 fn workflow_confirm_instruction(draft: &WorkflowDraftEnvelope) -> String {
110 let encoded = serde_json::to_string(draft).expect("workflow confirmation is serializable");
111 if let Some(source_path) = draft.source_path.as_deref() {
112 return format!(
113 "{WORKFLOW_CONFIRM_INSTRUCTION_PREFIX}{encoded}\n\
114 The user explicitly confirmed the saved Workflow source at {source_path:?}. Call the \
115 canonical `workflow` tool with `source_path` set to that exact relative path. Run the \
116 saved definition as-is; do not rewrite or replace it. Keep the existing approval, \
117 budget, cancellation, and receipt behavior."
118 );
119 }
120 format!(
121 "{WORKFLOW_CONFIRM_INSTRUCTION_PREFIX}{encoded}\n\
122 The user explicitly confirmed the Workflow proposal from the preceding review turn. \
123 Execute that reviewed plan through the canonical `workflow` tool now. Keep the existing \
124 approval, budget, scheduling, cancellation, and receipt behavior; do not teach or restate \
125 the tool schema."
126 )
127 }
128
129 fn envelope_from_instruction(prefix: &str, instruction: &str) -> Option<WorkflowDraftEnvelope> {
130 let first_line = instruction.lines().next()?;
131 let encoded = first_line.strip_prefix(prefix)?;
132 serde_json::from_str(encoded).ok()
133 }
134
135 fn user_instruction(message: &codewhale_models::Message) -> Option<&str> {
136 if message.role != "user" {
137 return None;
138 }
139 message.content.iter().find_map(|block| match block {
140 ContentBlock::Text { text, .. } => Some(text.as_str()),
141 _ => None,
142 })
143 }
144
145 enum PendingWorkflowDraft {
146 Ready(WorkflowDraftEnvelope),
147 NoDraft,
148 NotReviewed,
149 }
150
151 /// Find the latest reviewed, not-yet-confirmed Workflow draft.
152 ///
153 /// The rule set is deliberately small (the old five-branch scan kept biting
154 /// users: any ordinary message between the draft and `/workflow confirm`
155 /// cancelled the draft, so a confirm attempt after a failed first attempt
156 /// could never succeed):
157 ///
158 /// 1. An explicit confirm always picks the *latest* unconfirmed draft.
159 /// 2. Ordinary user/assistant messages in between are ignored — only a newer
160 /// draft instruction supersedes an older one (the scan is latest-first).
161 /// 3. The draft must have at least one assistant text reply after it; a draft
162 /// whose review turn is still in flight cannot be confirmed yet.
163 /// 4. A draft that was already confirmed (a confirm instruction exists for its
164 /// id) is never picked again.
165 fn pending_workflow_draft(app: &App) -> PendingWorkflowDraft {
166 let mut resolved = std::collections::HashSet::new();
167
168 for queued in app.queued_messages.iter().chain(app.queued_draft.iter()) {
169 if let Some(instruction) = queued.skill_instruction.as_deref()
170 && let Some(envelope) =
171 envelope_from_instruction(WORKFLOW_CONFIRM_INSTRUCTION_PREFIX, instruction)
172 {
173 resolved.insert(envelope.id);
174 }
175 }
176
177 let mut reviewed = false;
178 for message in app.api_messages.iter().rev() {
179 let Some(instruction) = user_instruction(message) else {
180 // Assistant text after the draft is the review reply. Tool-call
181 // turns and other assistant messages do not count as review.
182 if message.role == "assistant"
183 && message.content.iter().any(|block| {
184 matches!(block, ContentBlock::Text { text, .. } if !text.trim().is_empty())
185 })
186 {
187 reviewed = true;
188 }
189 continue;
190 };
191 if let Some(envelope) =
192 envelope_from_instruction(WORKFLOW_CONFIRM_INSTRUCTION_PREFIX, instruction)
193 {
194 resolved.insert(envelope.id);
195 continue;
196 }
197 if let Some(envelope) =
198 envelope_from_instruction(WORKFLOW_DRAFT_INSTRUCTION_PREFIX, instruction)
199 && !resolved.contains(&envelope.id)
200 {
201 return if reviewed {
202 PendingWorkflowDraft::Ready(envelope)
203 } else {
204 PendingWorkflowDraft::NotReviewed
205 };
206 }
207 // Ordinary user requests between draft and confirm are ignored: the
208 // explicit `/workflow confirm` means the current draft, not a new one.
209 }
210 PendingWorkflowDraft::NoDraft
211 }
212
213 pub fn workflow(app: &mut App, arg: Option<&str>) -> CommandResult {
214 let arg = arg.map(str::trim).filter(|value| !value.is_empty());
215
216 if let Some(action) = parse_workflow_control_action(app, arg) {
217 return action;
218 }
219
220 let id = uuid::Uuid::new_v4().to_string();
221 let objective =
222 arg.map(|objective| truncate_workflow_text(objective, WORKFLOW_OBJECTIVE_MAX_CHARS));
223 let display = workflow_display("Workflow draft: ", objective.as_deref());
224 CommandResult::with_message_and_action(
225 "Drafting a workflow for review. Nothing will run until /workflow confirm.",
226 AppAction::WorkflowInstruction {
227 display,
228 instruction: workflow_draft_instruction(&id, objective.as_deref()),
229 },
230 )
231 }
232
233 /// Host-side `status` / `runs` / `cancel` / `settings`: read the run journal and
234 /// live run state directly and answer without a model turn, so a status
235 /// check is free and a cancel lands even while the model is busy.
236 fn parse_workflow_control_action(app: &App, arg: Option<&str>) -> Option<CommandResult> {
237 let arg = arg?;
238 let (verb, rest) = match arg.split_once(char::is_whitespace) {
239 Some((verb, rest)) => (verb, rest.trim()),
240 None => (arg, ""),
241 };
242 match verb {
243 "confirm" if rest.is_empty() => Some(match pending_workflow_draft(app) {
244 PendingWorkflowDraft::Ready(draft) => {
245 let display = match draft.source_path.as_deref() {
246 Some(path) => workflow_display("Workflow file confirmed: ", Some(path)),
247 None => workflow_display("Workflow confirmed: ", draft.objective.as_deref()),
248 };
249 CommandResult::with_message_and_action(
250 "Workflow confirmed. Starting the reviewed plan...",
251 AppAction::WorkflowInstruction {
252 display,
253 instruction: workflow_confirm_instruction(&draft),
254 },
255 )
256 }
257 PendingWorkflowDraft::NotReviewed => CommandResult::error(
258 "The current Workflow draft has not been reviewed yet. Wait for the proposal turn to finish, then run /workflow confirm.",
259 ),
260 PendingWorkflowDraft::NoDraft => CommandResult::error(
261 "There is no Workflow draft to confirm. Use /workflow <objective> to draft one first.",
262 ),
263 }),
264 "status" | "runs" | "list" | "inspect" => Some(workflow_status(app, rest)),
265 "cancel" | "stop" | "abort" => Some(workflow_cancel(app, rest)),
266 "settings" | "config" => Some(super::super::config::workflow_settings(app)),
267 "help" | "?" => Some(CommandResult::message(WORKFLOW_USAGE)),
268 // Saved definitions use the same two-turn review/confirm gate as a
269 // conversational Workflow. The draft turn has an empty tool catalog;
270 // only the later confirmation can launch the exact checked-in path.
271 "run" if !rest.is_empty() && !rest.contains(char::is_whitespace) => {
272 let path = std::path::Path::new(rest);
273 let unsafe_path = path.is_absolute()
274 || path.components().any(|component| {
275 matches!(
276 component,
277 std::path::Component::ParentDir
278 | std::path::Component::RootDir
279 | std::path::Component::Prefix(_)
280 )
281 });
282 if unsafe_path || rest.chars().count() > WORKFLOW_OBJECTIVE_MAX_CHARS {
283 return Some(CommandResult::error(
284 "Workflow source must be a bounded relative path inside this workspace.",
285 ));
286 }
287 let id = uuid::Uuid::new_v4().to_string();
288 let display = workflow_display("Workflow file draft: ", Some(rest));
289 Some(CommandResult::with_message_and_action(
290 "Reviewing the saved workflow. Nothing will run until /workflow confirm.",
291 AppAction::WorkflowInstruction {
292 display,
293 instruction: workflow_source_draft_instruction(&id, rest),
294 },
295 ))
296 }
297 _ => None,
298 }
299 }
300
301 const WORKFLOW_USAGE: &str =
302 "/workflow <objective> — draft a Workflow for review (does not execute)
303 /workflow — draft a Workflow for the current work
304 /workflow run <path> — review a saved Workflow before it can run
305 /workflow confirm — explicitly start the latest reviewed draft
306 /workflow status [run_id] — runs known to this workspace (no model turn)
307 /workflow cancel [run_id] — stop a running workflow (no model turn)
308 /workflow settings — the effective [workflow] configuration
309 /workflows — the live run dashboard (opens in the TUI)";
310
311 fn describe_run(line: &crate::tools::workflow::HostWorkflowRunLine, now_ms: u64) -> String {
312 let elapsed = line
313 .completed_at_ms
314 .unwrap_or(now_ms)
315 .saturating_sub(line.started_at_ms)
316 / 1000;
317 let mut text = format!(
318 "{} {} {} {} {} children",
319 line.run_id,
320 line.status,
321 line.label,
322 crate::elapsed::format_elapsed_secs(elapsed),
323 line.child_count
324 );
325 if let Some(progress) = line.last_progress.as_deref() {
326 text.push_str(" · ");
327 text.push_str(progress);
328 }
329 if let Some(error) = line.error.as_deref() {
330 text.push_str(" · ");
331 text.push_str(error);
332 }
333 text
334 }
335
336 fn workflow_status(app: &App, run_id: &str) -> CommandResult {
337 let runs = crate::tools::workflow::host_workflow_runs(
338 &app.workspace,
339 app.current_session_id.as_deref(),
340 );
341 let now_ms = std::time::SystemTime::now()
342 .duration_since(std::time::UNIX_EPOCH)
343 .map(|d| d.as_millis() as u64)
344 .unwrap_or_default();
345 if !run_id.is_empty() {
346 return match runs.iter().find(|line| line.run_id == run_id) {
347 Some(line) => CommandResult::message(describe_run(line, now_ms)),
348 None => CommandResult::error(format!(
349 "Unknown workflow run '{run_id}'. /workflow status lists the runs this workspace knows."
350 )),
351 };
352 }
353 if runs.is_empty() {
354 return CommandResult::message(
355 "No workflow runs in this workspace yet. /workflow <objective> starts one.",
356 );
357 }
358 let active = runs.iter().filter(|line| line.active).count();
359 let mut lines = vec![format!(
360 "{} workflow run{} · {active} active",
361 runs.len(),
362 if runs.len() == 1 { "" } else { "s" }
363 )];
364 // Newest first; the journal can hold every run the workspace ever made.
365 for line in runs.iter().rev().take(20) {
366 lines.push(describe_run(line, now_ms));
367 }
368 if runs.len() > 20 {
369 lines.push(format!(
370 "… {} older runs in .codewhale/workflow-runs.jsonl",
371 runs.len() - 20
372 ));
373 }
374 CommandResult::message(lines.join("\n"))
375 }
376
377 fn workflow_cancel(app: &App, run_id: &str) -> CommandResult {
378 if run_id.contains(char::is_whitespace) {
379 return CommandResult::error("Usage: /workflow cancel [run_id]");
380 }
381 let target = if run_id.is_empty() {
382 let running: Vec<_> = crate::tools::workflow::host_workflow_runs(
383 &app.workspace,
384 app.current_session_id.as_deref(),
385 )
386 .into_iter()
387 .filter(|line| line.active)
388 .collect();
389 match running.as_slice() {
390 [] => return CommandResult::message("No workflow is active."),
391 [only] => only.run_id.clone(),
392 many => {
393 let ids: Vec<&str> = many.iter().map(|line| line.run_id.as_str()).collect();
394 return CommandResult::error(format!(
395 "{} workflows are active; name one: {}",
396 many.len(),
397 ids.join(", ")
398 ));
399 }
400 }
401 } else {
402 run_id.to_string()
403 };
404 match crate::tools::workflow::host_cancel_workflow(
405 &app.workspace,
406 &target,
407 app.current_session_id.as_deref(),
408 ) {
409 Ok(line) => CommandResult::message(format!(
410 "Workflow {} {} · {}",
411 line.run_id, line.status, line.label
412 )),
413 Err(reason) => CommandResult::error(reason),
414 }
415 }
416
417 /// `/workflows` — the live **run** dashboard (Grok-build parity for the
418 /// observation surface). Bare opens the manager view; the host control verbs
419 /// (`status`, `cancel`, `settings`) still answer inline from the run journal
420 /// so muscle memory from the old `/workflows` alias keeps working — none of
421 /// them spend a model turn. Anything else is redirected to `/workflow`, the
422 /// only surface that carries orchestration authority: `/workflows` observes
423 /// and cancels, it never launches.
424 pub(in crate::commands) const WORKFLOWS_COMMAND_INFO: CommandInfo = CommandInfo {
425 name: "workflows",
426 aliases: &[],
427 usage: "/workflows",
428 description_id: MessageId::CmdWorkflowsDescription,
429 };
430
431 pub(in crate::commands) struct WorkflowsCmd;
432
433 impl RegisterCommand for WorkflowsCmd {
434 fn info() -> &'static CommandInfo {
435 &WORKFLOWS_COMMAND_INFO
436 }
437
438 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
439 workflows(app, arg)
440 }
441 }
442
443 pub fn workflows(app: &mut App, arg: Option<&str>) -> CommandResult {
444 let arg = arg.map(str::trim).filter(|value| !value.is_empty());
445 let Some(arg) = arg else {
446 return CommandResult::action(AppAction::OpenWorkflowsManager);
447 };
448 let (verb, rest) = match arg.split_once(char::is_whitespace) {
449 Some((verb, rest)) => (verb, rest.trim()),
450 None => (arg, ""),
451 };
452 match verb {
453 "status" | "runs" | "list" | "inspect" => workflow_status(app, rest),
454 "cancel" | "stop" | "abort" => workflow_cancel(app, rest),
455 "settings" | "config" => super::super::config::workflow_settings(app),
456 "help" | "?" => CommandResult::message(WORKFLOWS_USAGE),
457 _ => CommandResult::error(
458 "/workflows observes runs — it never launches one. Use /workflow <objective> to run one, or bare /workflow to orchestrate the current work.",
459 ),
460 }
461 }
462
463 const WORKFLOWS_USAGE: &str = "/workflows — open the live run dashboard (no model turn)
464 /workflows status [run_id] — the same listing as text
465 /workflows cancel [run_id] — stop a running workflow (no model turn)
466 /workflows settings — the effective [workflow] configuration";
467
468 /// `/auto` is the third orchestration choice: work with Auto-Review.
469 /// Host-only alias for the existing permission posture — no new runtime (#5439).
470 pub(in crate::commands) const AUTO_COMMAND_INFO: CommandInfo = CommandInfo {
471 name: "auto",
472 aliases: &[],
473 usage: "/auto",
474 description_id: MessageId::CmdAutoDescription,
475 };
476
477 pub(in crate::commands) struct AutoCmd;
478
479 impl RegisterCommand for AutoCmd {
480 fn info() -> &'static CommandInfo {
481 &AUTO_COMMAND_INFO
482 }
483
484 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
485 auto(app, arg)
486 }
487 }
488
489 pub fn auto(app: &mut App, arg: Option<&str>) -> CommandResult {
490 if arg.map(str::trim).is_some_and(|value| !value.is_empty()) {
491 return CommandResult::error("Usage: /auto");
492 }
493 if let Err(reason) = app.apply_auto_review_posture() {
494 return CommandResult::error(reason);
495 }
496
497 let mut message = app.tr(MessageId::AutoReceiptOn).into_owned();
498 if app.mode == AppMode::Plan {
499 message.push(' ');
500 message.push_str(app.tr(MessageId::AutoReceiptPlanNote).as_ref());
501 }
502 CommandResult::message(message)
503 }
504
505 #[cfg(test)]
506 mod tests {
507 use super::*;
508 use codewhale_models::Role;
509 use std::path::PathBuf;
510
511 use crate::tui::app::TuiOptions;
512
513 fn test_app() -> App {
514 let options = TuiOptions {
515 ..crate::test_support::test_tui_options(PathBuf::from("."))
516 };
517 App::new(options, &crate::config::Config::default())
518 }
519
520 #[test]
521 fn auto_sets_auto_review_and_explains_the_trio() {
522 let mut app = test_app();
523 app.ui_locale = codewhale_localization::Locale::En;
524 app.set_agent_approval_posture(ApprovalMode::Suggest);
525
526 let result = auto(&mut app, None);
527 assert!(!result.is_error, "{:?}", result.message);
528 assert_eq!(app.approval_mode, ApprovalMode::Auto);
529 let text = result.message.as_deref().unwrap();
530 assert!(text.contains("Auto-Review"));
531 assert!(text.contains("/goal"));
532 assert!(text.contains("/workflow"));
533 assert!(result.action.is_none());
534 }
535
536 #[test]
537 fn auto_rejects_arguments() {
538 let mut app = test_app();
539 let result = auto(&mut app, Some("now"));
540 assert!(result.is_error);
541 assert!(result.message.as_deref().unwrap().contains("Usage: /auto"));
542 }
543
544 #[test]
545 fn ordinary_workflow_is_a_toolless_review_turn() {
546 let mut app = test_app();
547 let result = workflow(&mut app, Some("audit provider error handling"));
548 assert!(!result.is_error);
549 let Some(AppAction::WorkflowInstruction {
550 display,
551 instruction,
552 }) = result.action
553 else {
554 panic!("expected WorkflowInstruction action");
555 };
556 assert!(display.contains("audit provider error handling"));
557 assert!(!display.contains("workflow` tool"));
558 assert!(
559 display.len() < 80,
560 "visible transcript line must stay compact"
561 );
562 assert!(instruction.starts_with(WORKFLOW_DRAFT_INSTRUCTION_PREFIX));
563 assert!(
564 instruction.len() < 700,
565 "draft instruction grew into a manual"
566 );
567 assert!(!instruction.contains("parallel()"));
568 assert!(!instruction.contains("responseSchema"));
569
570 let queued = crate::tui::app::QueuedMessage::new(display, Some(instruction));
571 assert_eq!(
572 crate::tui::ui::allowed_tools_for_message(None, &queued),
573 Some(Vec::new()),
574 "the host, not model compliance, must prevent same-turn execution"
575 );
576 }
577
578 #[test]
579 fn oversized_multibyte_objective_is_bounded_once_and_confirmed_exactly() {
580 let mut app = test_app();
581 let original = "鲸".repeat(WORKFLOW_OBJECTIVE_MAX_CHARS + 50);
582 let drafted = workflow(&mut app, Some(&original));
583 let Some(AppAction::WorkflowInstruction {
584 display,
585 instruction,
586 }) = drafted.action
587 else {
588 panic!("expected WorkflowInstruction action");
589 };
590
591 assert!(display.chars().count() <= WORKFLOW_DISPLAY_MAX_CHARS);
592 assert!(!display.contains('\n'));
593 let draft = envelope_from_instruction(WORKFLOW_DRAFT_INSTRUCTION_PREFIX, &instruction)
594 .expect("typed workflow draft envelope");
595 let objective = draft.objective.as_deref().expect("bounded objective");
596 assert_eq!(objective.chars().count(), WORKFLOW_OBJECTIVE_MAX_CHARS);
597 assert!(objective.ends_with('…'));
598
599 app.api_messages_mut().push(codewhale_models::Message {
600 role: Role::User,
601 content: vec![ContentBlock::Text {
602 text: instruction,
603 cache_control: None,
604 }],
605 });
606 app.api_messages_mut().push(codewhale_models::Message {
607 role: Role::Assistant,
608 content: vec![ContentBlock::Text {
609 text: "Reviewed bounded objective and proposed phases.".to_string(),
610 cache_control: None,
611 }],
612 });
613
614 let confirmed = workflow(&mut app, Some("confirm"));
615 let Some(AppAction::WorkflowInstruction {
616 display,
617 instruction,
618 }) = confirmed.action
619 else {
620 panic!("expected confirmed WorkflowInstruction action");
621 };
622 assert!(display.chars().count() <= WORKFLOW_DISPLAY_MAX_CHARS);
623 let confirmed =
624 envelope_from_instruction(WORKFLOW_CONFIRM_INSTRUCTION_PREFIX, &instruction)
625 .expect("typed workflow confirmation envelope");
626 assert_eq!(confirmed.objective.as_deref(), Some(objective));
627 }
628
629 #[test]
630 fn confirm_survives_ordinary_messages_between_draft_and_confirm() {
631 let mut app = test_app();
632 let drafted = workflow(&mut app, Some("audit provider error handling"));
633 let Some(AppAction::WorkflowInstruction {
634 display,
635 instruction,
636 }) = drafted.action
637 else {
638 panic!("expected WorkflowInstruction action");
639 };
640 app.api_messages_mut().push(codewhale_models::Message {
641 role: Role::User,
642 content: vec![ContentBlock::Text {
643 text: format!("{instruction}\n\n---\n\nUser request: {display}"),
644 cache_control: None,
645 }],
646 });
647 app.api_messages_mut().push(codewhale_models::Message {
648 role: Role::Assistant,
649 content: vec![ContentBlock::Text {
650 text: "Objective, phases, workers, and risks. Run /workflow confirm to start."
651 .to_string(),
652 cache_control: None,
653 }],
654 });
655
656 // The user types or the model replies again before confirming — the
657 // explicit confirm must still find the reviewed draft (regression: the
658 // old supersede rule cancelled the draft on any ordinary message and
659 // made repeated confirm attempts impossible).
660 app.api_messages_mut().push(codewhale_models::Message {
661 role: Role::User,
662 content: vec![ContentBlock::Text {
663 text: "hmm it won't let me confirm it lol".to_string(),
664 cache_control: None,
665 }],
666 });
667 let confirmed = workflow(&mut app, Some("confirm"));
668 assert!(!confirmed.is_error, "{:?}", confirmed.message);
669 let Some(AppAction::WorkflowInstruction { instruction, .. }) = confirmed.action else {
670 panic!("expected confirmed WorkflowInstruction action");
671 };
672 assert!(instruction.starts_with(WORKFLOW_CONFIRM_INSTRUCTION_PREFIX));
673
674 // A newer DRAFT still supersedes the older one.
675 let redrafted = workflow(&mut app, Some("fix the confirm flow"));
676 let Some(AppAction::WorkflowInstruction {
677 instruction: redraft_instruction,
678 display: redraft_display,
679 }) = redrafted.action
680 else {
681 panic!("expected WorkflowInstruction action");
682 };
683 app.api_messages_mut().push(codewhale_models::Message {
684 role: Role::User,
685 content: vec![ContentBlock::Text {
686 text: format!("{redraft_instruction}\n\n---\n\nUser request: {redraft_display}"),
687 cache_control: None,
688 }],
689 });
690 let still_unreviewed = workflow(&mut app, Some("confirm"));
691 assert!(
692 still_unreviewed.is_error
693 && still_unreviewed
694 .message
695 .as_deref()
696 .unwrap_or_default()
697 .contains("not been reviewed"),
698 "a newer, unreviewed draft must be confirmed only after its review: {:?}",
699 still_unreviewed.message
700 );
701 }
702
703 #[test]
704 fn workflow_confirmation_is_a_separate_tool_enabled_turn() {
705 let mut app = test_app();
706 let drafted = workflow(&mut app, Some("audit provider error handling"));
707 let Some(AppAction::WorkflowInstruction {
708 display,
709 instruction,
710 }) = drafted.action
711 else {
712 panic!("expected WorkflowInstruction action");
713 };
714 app.api_messages_mut().push(codewhale_models::Message {
715 role: Role::User,
716 content: vec![ContentBlock::Text {
717 text: format!("{instruction}\n\n---\n\nUser request: {display}"),
718 cache_control: None,
719 }],
720 });
721 assert!(
722 workflow(&mut app, Some("confirm")).is_error,
723 "a failed or unfinished draft turn is not a reviewed plan"
724 );
725 app.api_messages_mut().push(codewhale_models::Message {
726 role: Role::Assistant,
727 content: vec![ContentBlock::Text {
728 text: "Objective, phases, workers, and risks. Run /workflow confirm to start."
729 .to_string(),
730 cache_control: None,
731 }],
732 });
733
734 let confirmed = workflow(&mut app, Some("confirm"));
735 assert!(!confirmed.is_error);
736 let Some(AppAction::WorkflowInstruction {
737 display,
738 instruction,
739 }) = confirmed.action
740 else {
741 panic!("expected confirmed WorkflowInstruction action");
742 };
743 assert!(instruction.starts_with(WORKFLOW_CONFIRM_INSTRUCTION_PREFIX));
744 let queued = crate::tui::app::QueuedMessage::new(display, Some(instruction.clone()));
745 assert_eq!(
746 crate::tui::ui::allowed_tools_for_message(None, &queued),
747 None,
748 "only the later explicit confirmation restores the normal catalog"
749 );
750
751 app.api_messages_mut().push(codewhale_models::Message {
752 role: Role::User,
753 content: vec![ContentBlock::Text {
754 text: instruction,
755 cache_control: None,
756 }],
757 });
758 let replay = workflow(&mut app, Some("confirm"));
759 assert!(replay.is_error, "one draft may not be confirmed twice");
760 }
761
762 #[test]
763 fn workflow_status_and_cancel_answer_from_the_host_without_a_model_turn() {
764 let dir = tempfile::tempdir().expect("tempdir");
765 let mut app = test_app();
766 app.workspace = dir.path().to_path_buf();
767 app.current_session_id = Some("workflow-host-test-session".to_string());
768
769 // Nothing has run in this workspace: status is a plain answer, and it
770 // must not create the run journal just to say so.
771 let result = workflow(&mut app, Some("status"));
772 assert!(!result.is_error);
773 assert!(
774 result.action.is_none(),
775 "status must not send a model message"
776 );
777 assert!(
778 result
779 .message
780 .as_deref()
781 .unwrap()
782 .contains("No workflow runs")
783 );
784 assert!(!dir.path().join(".codewhale/workflow-runs.jsonl").exists());
785
786 let result = workflow(&mut app, Some("status wf_missing"));
787 assert!(result.is_error);
788 assert!(result.action.is_none());
789
790 // A seeded run is listed and described from host state.
791 crate::tools::workflow::structcopy_test_seed_run(
792 dir.path(),
793 "workflow_seed",
794 app.current_session_id
795 .as_deref()
796 .expect("test session identity"),
797 );
798 let result = workflow(&mut app, Some("runs"));
799 let text = result.message.unwrap();
800 assert!(text.contains("workflow_seed"), "{text}");
801 assert!(text.contains("queued"), "{text}");
802 assert!(result.action.is_none());
803
804 // Cancel with one active run needs no id and never asks the model.
805 // The seeded record has no live controller (no VM ran); cancel still
806 // marks the journal cancelled with an honest nothing-live receipt.
807 let result = workflow(&mut app, Some("cancel"));
808 assert!(result.action.is_none());
809 assert!(!result.is_error, "{:?}", result.message);
810 let text = result.message.as_deref().unwrap();
811 assert!(text.contains("workflow_seed"), "{text}");
812 assert!(text.contains("cancelled"), "{text}");
813 let after = crate::tools::workflow::host_workflow_runs(
814 &app.workspace,
815 app.current_session_id.as_deref(),
816 );
817 assert_eq!(
818 after
819 .iter()
820 .find(|line| line.run_id == "workflow_seed")
821 .map(|line| line.status),
822 Some("cancelled")
823 );
824
825 let result = workflow(&mut app, Some("cancel with spaces"));
826 assert!(result.is_error);
827
828 let result = workflow(&mut app, Some("help"));
829 assert!(result.message.unwrap().contains("/workflow status"));
830
831 // `/workflow run <path>` now drafts a tool-less review. Only a later
832 // explicit confirmation may launch the exact saved source.
833 let result = workflow(&mut app, Some("run workflows/tiny.workflow.js"));
834 let Some(AppAction::WorkflowInstruction {
835 display,
836 instruction,
837 }) = result.action
838 else {
839 panic!("expected WorkflowInstruction action");
840 };
841 assert!(display.contains("workflows/tiny.workflow.js"), "{display}");
842 let draft = envelope_from_instruction(WORKFLOW_DRAFT_INSTRUCTION_PREFIX, &instruction)
843 .expect("typed saved-workflow draft");
844 assert_eq!(
845 draft.source_path.as_deref(),
846 Some("workflows/tiny.workflow.js")
847 );
848 let queued = crate::tui::app::QueuedMessage::new(display, Some(instruction.clone()));
849 assert_eq!(
850 crate::tui::ui::allowed_tools_for_message(None, &queued),
851 Some(Vec::new())
852 );
853
854 app.api_messages_mut().push(codewhale_models::Message {
855 role: Role::User,
856 content: vec![ContentBlock::Text {
857 text: instruction,
858 cache_control: None,
859 }],
860 });
861 app.api_messages_mut().push(codewhale_models::Message {
862 role: Role::Assistant,
863 content: vec![ContentBlock::Text {
864 text: "Saved Workflow path and risks reviewed.".to_string(),
865 cache_control: None,
866 }],
867 });
868 let confirmed = workflow(&mut app, Some("confirm"));
869 let Some(AppAction::WorkflowInstruction { instruction, .. }) = confirmed.action else {
870 panic!("expected confirmed saved Workflow action");
871 };
872 assert!(instruction.contains("`source_path`"), "{instruction}");
873 assert!(
874 instruction.contains("workflows/tiny.workflow.js"),
875 "{instruction}"
876 );
877
878 assert!(workflow(&mut app, Some("run ../outside.workflow.js")).is_error);
879 assert!(workflow(&mut app, Some("run /tmp/outside.workflow.js")).is_error);
880 }
881
882 #[test]
883 fn workflows_opens_the_run_dashboard_and_keeps_host_verbs_free() {
884 let dir = tempfile::tempdir().expect("tempdir");
885 let mut app = test_app();
886 app.workspace = dir.path().to_path_buf();
887 app.current_session_id = Some("workflow-host-test-session".to_string());
888
889 // Bare `/workflows` opens the dashboard: a host action, never a
890 // model turn — observation carries no orchestration authority.
891 let result = workflows(&mut app, None);
892 assert!(!result.is_error);
893 assert!(matches!(
894 result.action,
895 Some(AppAction::OpenWorkflowsManager)
896 ));
897
898 // Host control verbs still answer inline (the old alias surface).
899 let result = workflows(&mut app, Some("status"));
900 assert!(!result.is_error);
901 assert!(
902 result.action.is_none(),
903 "status must not send a model message"
904 );
905 assert!(
906 result
907 .message
908 .as_deref()
909 .unwrap()
910 .contains("No workflow runs")
911 );
912
913 // Orchestration attempts are redirected to /workflow, the only
914 // surface that carries launch authority.
915 let result = workflows(&mut app, Some("audit provider errors"));
916 assert!(result.is_error);
917 assert!(result.action.is_none());
918 assert!(result.message.unwrap().contains("never launches"));
919 }
920
921 #[test]
922 fn workflow_settings_explains_the_session_table() {
923 let mut app = test_app();
924 app.workflow_config.automatic = false;
925 app.workflow_config.require_approval_for_writes = false;
926 app.goal_max_continuations = 25;
927 let result = workflow(&mut app, Some("settings"));
928 assert!(result.action.is_none());
929 let text = result.message.unwrap();
930 assert!(text.contains("automatic = off"), "{text}");
931 assert!(text.contains("require_approval_for_writes = off"), "{text}");
932 assert!(text.contains("max_continuations = 25"), "{text}");
933 }
934
935 #[test]
936 fn workflow_settings_and_tool_share_a_refreshed_session_table() {
937 use crate::tools::spec::{ApprovalRequirement, ToolContext, ToolSpec};
938 use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager};
939 use crate::tools::workflow::WorkflowTool;
940 use serde_json::json;
941
942 let dir = tempfile::tempdir().expect("tempdir");
943 let mut app = test_app();
944 app.workspace = dir.path().to_path_buf();
945
946 let mut table = app.workflow_config.clone();
947 table.automatic = false;
948 table.require_approval_for_writes = false;
949 table.auto_start_read_only = false;
950 crate::tools::workflow::set_session_workflow_config(&app.workspace, table.clone());
951 app.workflow_config = table;
952
953 let result = workflow(&mut app, Some("settings"));
954 assert!(result.action.is_none());
955 let text = result.message.unwrap();
956 assert!(text.contains("automatic = off"), "{text}");
957 assert!(text.contains("require_approval_for_writes = off"), "{text}");
958 assert!(text.contains("auto_start_read_only = off"), "{text}");
959
960 let ctx = ToolContext::new(dir.path().to_path_buf());
961 let manager = new_shared_subagent_manager(dir.path().to_path_buf(), 2);
962 let _ = rustls::crypto::ring::default_provider().install_default();
963 let client = crate::client::CodewhaleClient::new(&crate::config::Config {
964 api_key: Some("test-key".to_string()),
965 ..crate::config::Config::default()
966 })
967 .expect("stub client");
968 let mut runtime = SubAgentRuntime::new(
969 client,
970 "deepseek-v4-flash".to_string(),
971 ctx,
972 true,
973 None,
974 manager.clone(),
975 );
976 // Stale snapshot: product defaults still require write approval.
977 runtime.api_config = Some(std::sync::Arc::new(crate::config::Config::default()));
978 let tool = WorkflowTool::new(manager, runtime);
979
980 let write_plan = json!({
981 "action": "start",
982 "plan": {
983 "goal": "write freely",
984 "risk": "writes",
985 "children": [{ "prompt": "edit", "type": "implementer" }]
986 }
987 });
988 let read_only = json!({
989 "action": "start",
990 "plan": {
991 "goal": "scout crates",
992 "risk": "read_only",
993 "children": [{ "prompt": "look", "type": "explore" }]
994 }
995 });
996 assert_eq!(
997 tool.approval_requirement_for(&write_plan),
998 ApprovalRequirement::Auto,
999 "refreshed require_approval_for_writes = false must win over the stale runtime snapshot"
1000 );
1001 assert_eq!(
1002 tool.approval_requirement_for(&read_only),
1003 ApprovalRequirement::Required,
1004 "refreshed auto_start_read_only = false must still ask"
1005 );
1006 }
1007 }
1008
1008 lines RUST