返回 CodeWhale
goal.rs
根目录 / crates / tui / src / commands / groups / project / goal.rs
1 //! `/goal` — codex-style thread goals: set, inspect, pause, resume, and close
2 //! a durable objective. The engine owns the goal: setting or resuming one
3 //! starts work through the runtime's continuation steering, never by echoing
4 //! the objective back as a user message.
5 //!
6 //! FEAT-021 converts this handler to the portable command contract: it
7 //! consumes the typed project goal projection and the presentation facet only.
8 //! `CommandResult` and the emitted `AppAction` variants (`SetGoalStatus`,
9 //! `SetGoalObjective`, `SendMessage`) remain temporary TUI-owned data-only
10 //! references until FEAT-037.
11
12 use codewhale_command_contract::facets::{
13 CommandPresentationContext, ProjectGoalState, ProjectGoalStatus,
14 };
15 use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
16 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
17
18 use crate::tui::app::AppAction;
19
20 use crate::commands::CommandResult;
21
22 /// Translate one stable project key through the presentation facet.
23 fn translate(presentation: &mut dyn CommandPresentationContext, key: &str) -> String {
24 presentation.translate(key, &[]).unwrap_or_default()
25 }
26
27 /// Map the portable goal status onto the TUI-owned action payload status.
28 ///
29 /// This is the bounded FEAT-037 temporary action reference: the handler only
30 /// constructs data-only `AppAction` payloads and never touches the goal
31 /// service, config, or session manager.
32 fn to_action_status(status: ProjectGoalStatus) -> crate::tools::goal::GoalStatus {
33 match status {
34 ProjectGoalStatus::Active => crate::tools::goal::GoalStatus::Active,
35 ProjectGoalStatus::Paused => crate::tools::goal::GoalStatus::Paused,
36 ProjectGoalStatus::Complete => crate::tools::goal::GoalStatus::Complete,
37 ProjectGoalStatus::Blocked => crate::tools::goal::GoalStatus::Blocked,
38 }
39 }
40
41 /// Declare, show, pause, resume, or close a goal.
42 fn goal_command(
43 goal: &ProjectGoalState,
44 presentation: &mut dyn CommandPresentationContext,
45 arg: Option<&str>,
46 ) -> CommandResult {
47 match arg {
48 Some("clear") | Some("reset") => CommandResult::action(AppAction::SetGoalStatus {
49 status: crate::tools::goal::GoalStatus::Active,
50 clear: true,
51 }),
52 Some("done") | Some("complete") => {
53 close_goal(goal, presentation, ProjectGoalStatus::Complete)
54 }
55 Some("pause") | Some("paused") => close_goal(goal, presentation, ProjectGoalStatus::Paused),
56 Some("resume") | Some("continue") => resume_goal(goal, presentation),
57 Some("help") | Some("?") | Some("usage") => CommandResult::message(goal_usage()),
58 Some("status") | Some("show") => goal_status(goal, presentation),
59 Some("block") | Some("blocked") => {
60 close_goal(goal, presentation, ProjectGoalStatus::Blocked)
61 }
62 Some(text) if !text.is_empty() => {
63 let (objective, budget) = parse_goal_budget(text);
64 if objective.is_empty() || objective.chars().all(|c| c == '|') {
65 return CommandResult::error(goal_usage());
66 }
67 // The command layer never mutates the visible projection. The UI
68 // first persists and accepts this typed intent; only the engine's
69 // authoritative GoalUpdated event may change what the user sees.
70 CommandResult::action(AppAction::SetGoalObjective {
71 objective,
72 token_budget: budget,
73 })
74 }
75 _ => {
76 if goal.pending_controls {
77 CommandResult::message(translate(presentation, "goal_control_accepted"))
78 } else if goal.objective.is_some() {
79 goal_status(goal, presentation)
80 } else if !goal.conversation_present {
81 // Nothing has happened yet: there is no context to derive an
82 // objective from, so answer with usage instead of spending a
83 // model turn on a question we already know the answer to.
84 CommandResult::message(goal_usage())
85 } else {
86 // Context-dependent bare /goal: with no active goal, the
87 // invocation itself is the ask — derive the objective from
88 // the conversation instead of demanding a restatement
89 // (mirrors bare /workflow). The end-of-turn GoalUpdated
90 // snapshot syncs the created goal into the sidebar.
91 let message = "The user invoked /goal with no objective — declare a goal for the \
92 CURRENT work. Synthesize the objective from the conversation context (the \
93 task in flight, recent findings, open items) and set it by calling \
94 `create_goal` with the full objective (and a token_budget only if one was \
95 discussed). Then continue working toward it. Only if the conversation \
96 genuinely contains no work yet, ask the user what the goal should be."
97 .to_string();
98 CommandResult::with_message_and_action(
99 "Declaring a goal from the current context...",
100 AppAction::SendMessage(message),
101 )
102 }
103 }
104 }
105 }
106
107 /// Plain status line: objective, state, elapsed, budget, continuations, and
108 /// — for an active goal that no turn is driving right now — how to continue.
109 fn goal_status(
110 goal: &ProjectGoalState,
111 presentation: &mut dyn CommandPresentationContext,
112 ) -> CommandResult {
113 let Some(obj) = goal.objective.as_deref() else {
114 return CommandResult::message(goal_usage());
115 };
116 let elapsed = if goal.time_used_seconds > 0 {
117 format_elapsed(goal.time_used_seconds)
118 } else if let Some(secs) = goal.started_at_elapsed_seconds {
119 format_elapsed(secs)
120 } else {
121 "unknown".to_string()
122 };
123 let budget_str = goal
124 .token_budget
125 .map(|b| {
126 let used = if goal.tokens_used > 0 {
127 goal.tokens_used
128 } else {
129 u64::from(goal.session_total_tokens)
130 };
131 let pct = if b > 0 {
132 (used as f64 / f64::from(b) * 100.0).min(100.0)
133 } else {
134 0.0
135 };
136 format!(" · tokens {used}/{b} ({pct:.0}%)")
137 })
138 .unwrap_or_default();
139 let mut state = goal_status_label(goal.status).to_string();
140 if let (ProjectGoalStatus::Paused, Some(reason)) = (goal.status, goal.pause_reason.as_deref()) {
141 state = format!("{state} ({reason})");
142 }
143 let mut line = format!(
144 "Goal {state}: \"{obj}\" · elapsed {elapsed}{budget_str} · continuations {}",
145 goal.continuation_count
146 );
147 if goal.status == ProjectGoalStatus::Active
148 && !goal.is_loading
149 && !goal.goal_continuation_waiting
150 {
151 line.push_str(" · ");
152 line.push_str(&translate(presentation, "goal_status_idle_hint"));
153 }
154 CommandResult::message(line)
155 }
156
157 /// Close out the goal at `status`. Pure control plane: the engine stops (or
158 /// re-arms) the continuation loop from the `SetGoalStatus` op; no model turn
159 /// is dispatched.
160 fn close_goal(
161 goal: &ProjectGoalState,
162 presentation: &mut dyn CommandPresentationContext,
163 status: ProjectGoalStatus,
164 ) -> CommandResult {
165 if effective_goal_objective(goal).is_none_or(str::is_empty) {
166 return CommandResult::error("No goal set. Use /goal <objective> [budget: N] first.");
167 }
168 if effective_goal_status(goal) == status {
169 if goal.pending_controls {
170 return CommandResult::message(translate(presentation, "goal_control_accepted"));
171 }
172 return goal_status(goal, presentation);
173 }
174
175 CommandResult::action(AppAction::SetGoalStatus {
176 status: to_action_status(status),
177 clear: false,
178 })
179 }
180
181 /// Resume a paused goal. The engine restarts the continuation loop itself
182 /// (`SetGoalStatus` → schedule kickoff); the objective is never re-sent as a
183 /// user message.
184 fn resume_goal(
185 goal: &ProjectGoalState,
186 presentation: &mut dyn CommandPresentationContext,
187 ) -> CommandResult {
188 if effective_goal_objective(goal)
189 .map(str::trim)
190 .is_none_or(str::is_empty)
191 {
192 return CommandResult::error("No paused goal set. Use /goal <objective> first.");
193 }
194
195 // Resuming an already-active goal is a no-op: the continuation loop is
196 // already running, and re-asserting Active could stack a second
197 // autonomous turn. Report progress instead.
198 if effective_goal_status(goal) == ProjectGoalStatus::Active {
199 if goal.pending_controls {
200 return CommandResult::message(translate(presentation, "goal_control_accepted"));
201 }
202 return goal_status(goal, presentation);
203 }
204
205 CommandResult::action(AppAction::SetGoalStatus {
206 status: crate::tools::goal::GoalStatus::Active,
207 clear: false,
208 })
209 }
210
211 fn effective_goal_objective(goal: &ProjectGoalState) -> Option<&str> {
212 if goal.pending_controls {
213 goal.last_known_objective.as_deref()
214 } else {
215 goal.objective.as_deref()
216 }
217 }
218
219 fn effective_goal_status(goal: &ProjectGoalState) -> ProjectGoalStatus {
220 if goal.pending_controls {
221 goal.last_known_status.unwrap_or(ProjectGoalStatus::Active)
222 } else {
223 goal.status
224 }
225 }
226
227 fn goal_usage() -> &'static str {
228 "No goal set. /goal <objective> [budget: N] starts one; the agent works toward it \
229 across turns until it is verified complete, blocked, or you stop it.\n\
230 /goal — progress of the current goal\n\
231 /goal pause — pause without continuing\n\
232 /goal resume — resume and continue\n\
233 /goal done — mark complete (skips the model's verification)\n\
234 /goal blocked — mark blocked\n\
235 /goal clear — remove the current goal."
236 }
237
238 fn goal_status_label(status: ProjectGoalStatus) -> &'static str {
239 match status {
240 ProjectGoalStatus::Active => "active",
241 ProjectGoalStatus::Complete => "complete",
242 ProjectGoalStatus::Paused => "paused",
243 ProjectGoalStatus::Blocked => "blocked",
244 }
245 }
246
247 /// Format a whole-seconds duration (portable replica of the TUI leaf helper,
248 /// byte-identical output; the contract never ships preformatted strings).
249 fn format_elapsed(secs: u64) -> String {
250 if secs < 60 {
251 format!("{secs}s")
252 } else {
253 format!("{}m {:02}s", secs / 60, secs % 60)
254 }
255 }
256
257 /// Parse text like "Implement login | budget: 50000" into (objective, budget).
258 fn parse_goal_budget(text: &str) -> (String, Option<u32>) {
259 // Only an explicit, well-formed budget suffix splits the objective.
260 // `budget:` followed by something that is not a number is prose that
261 // belongs to the objective — truncating it would silently rewrite what
262 // the user asked for.
263 for separator in [" | budget:", " budget:", "budget:"] {
264 if let Some((objective, rest)) = text.split_once(separator) {
265 let budget = rest
266 .split_whitespace()
267 .next()
268 .and_then(|value| value.parse::<u32>().ok());
269 if let Some(budget) = budget {
270 return (objective.trim().to_string(), Some(budget));
271 }
272 }
273 }
274 (text.trim().to_string(), None)
275 }
276
277 pub(in crate::commands) const GOAL_INFO: CommandInfo = CommandInfo {
278 name: "goal",
279 aliases: &[],
280 usage: "/goal [objective|status|pause|resume|done|blocked|clear] [budget: N]",
281 description_key: "cmd_goal_description",
282 };
283
284 pub(in crate::commands) struct GoalCmd;
285
286 impl RegisterCommand<CommandResult> for GoalCmd {
287 fn info() -> &'static CommandInfo {
288 &GOAL_INFO
289 }
290
291 fn handler() -> CommandHandler<CommandResult> {
292 CommandHandler::Contextual {
293 capabilities: codewhale_command_contract::handler::CommandCapabilities::PROJECT
294 .union(codewhale_command_contract::handler::CommandCapabilities::PRESENTATION),
295 handler: goal_contextual,
296 }
297 }
298 }
299
300 /// Contextual `/goal` dispatch (FEAT-021 Phase 4).
301 ///
302 /// Destructures the declared `PROJECT | PRESENTATION` facets with safe
303 /// missing-facet errors; the portable handler never panics on absent
304 /// capabilities and never emits a partial action.
305 fn goal_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
306 let mut parts = contexts.into_parts();
307 let Some(project) = parts.project.as_deref_mut() else {
308 return CommandResult::error("Command capability unavailable: project");
309 };
310 let Some(presentation) = parts.presentation.as_deref_mut() else {
311 return CommandResult::error("Command capability unavailable: presentation");
312 };
313 let goal = project.goal_state();
314 goal_command(&goal, presentation, arg)
315 }
316
317 #[cfg(test)]
318 mod tests {
319 use super::*;
320 use codewhale_command_contract::facets::{CommandProjectContext, ProjectShareProjection};
321
322 /// Deterministic fake project facet over portable values only.
323 struct FakeProject;
324
325 impl FakeProject {
326 fn new() -> Self {
327 Self
328 }
329 }
330
331 impl CommandProjectContext for FakeProject {
332 fn lsp_enabled(&self) -> bool {
333 false
334 }
335
336 fn lsp_set(&mut self, _enabled: bool) -> Result<(), String> {
337 Ok(())
338 }
339
340 fn share_projection(&self) -> ProjectShareProjection {
341 ProjectShareProjection {
342 history_is_empty: true,
343 history_len: 0,
344 model: String::new(),
345 mode_label: String::new(),
346 }
347 }
348
349 fn goal_state(&self) -> ProjectGoalState {
350 goal_state()
351 }
352 }
353
354 /// Deterministic fake presentation facet over portable values only.
355 struct FakePresentation;
356
357 impl CommandPresentationContext for FakePresentation {
358 fn translate(&self, key: &str, _replacements: &[(&str, &str)]) -> Result<String, String> {
359 match key {
360 "goal_control_accepted" => {
361 Ok("Goal control saved; applying at the next safe boundary.".to_string())
362 }
363 "goal_status_idle_hint" => {
364 Ok("not running now — send a message or /goal resume to continue".to_string())
365 }
366 other => Err(format!("unknown key {other}")),
367 }
368 }
369 }
370
371 fn goal_state() -> ProjectGoalState {
372 ProjectGoalState {
373 objective: None,
374 status: ProjectGoalStatus::Active,
375 pause_reason: None,
376 started_at_elapsed_seconds: None,
377 time_used_seconds: 0,
378 token_budget: None,
379 tokens_used: 0,
380 session_total_tokens: 0,
381 continuation_count: 0,
382 pending_controls: false,
383 last_known_objective: None,
384 last_known_status: None,
385 conversation_present: false,
386 is_loading: false,
387 goal_continuation_waiting: false,
388 }
389 }
390
391 fn run(goal: &ProjectGoalState, arg: Option<&str>) -> CommandResult {
392 let mut presentation = FakePresentation;
393 goal_command(goal, &mut presentation, arg)
394 }
395
396 #[test]
397 fn test_set_goal_dispatches_control_plane_not_user_echo() {
398 let goal = goal_state();
399 let result = run(&goal, Some("Fix the login bug"));
400 assert!(result.message.is_none());
401 // The engine owns the kickoff: the objective must reach it as a
402 // SetGoalObjective control op, never as a SendMessage user echo.
403 assert!(matches!(
404 result.action,
405 Some(AppAction::SetGoalObjective { ref objective, token_budget: None })
406 if objective == "Fix the login bug"
407 ));
408 }
409
410 #[test]
411 fn test_goal_budget_parsing_reaches_the_op() {
412 let goal = goal_state();
413 let result = run(&goal, Some("Ship 0.9.10 | budget: 5000"));
414 assert!(matches!(
415 result.action,
416 Some(AppAction::SetGoalObjective { ref objective, token_budget: Some(5000) })
417 if objective == "Ship 0.9.10"
418 ));
419 }
420
421 #[test]
422 fn pause_and_clear_are_control_ops_without_optimistic_state() {
423 let mut goal = goal_state();
424 goal.objective = Some("Keep the build green".to_string());
425 goal.status = ProjectGoalStatus::Active;
426 let paused = run(&goal, Some("pause"));
427 assert!(paused.message.is_none());
428 assert!(matches!(
429 paused.action,
430 Some(AppAction::SetGoalStatus {
431 status: crate::tools::goal::GoalStatus::Paused,
432 clear: false
433 })
434 ));
435
436 let cleared = run(&goal, Some("clear"));
437 assert!(cleared.message.is_none());
438 assert!(matches!(
439 cleared.action,
440 Some(AppAction::SetGoalStatus {
441 status: crate::tools::goal::GoalStatus::Active,
442 clear: true
443 })
444 ));
445 }
446
447 #[test]
448 fn test_goal_without_argument_synthesizes_goal_from_context() {
449 // Bare /goal with no active goal is context-dependent: the model
450 // derives the objective from the conversation and sets it via
451 // create_goal — it must not error with a usage demand.
452 let mut goal = goal_state();
453 goal.conversation_present = true;
454 let result = run(&goal, None);
455 assert!(!result.is_error);
456 let Some(AppAction::SendMessage(message)) = result.action else {
457 panic!("expected SendMessage action");
458 };
459 assert!(message.contains("Synthesize the objective from the conversation"));
460 assert!(message.contains("`create_goal`"));
461 }
462
463 #[test]
464 fn bare_goal_on_an_empty_session_prints_usage_without_a_model_turn() {
465 // No conversation yet: there is nothing to derive an objective from,
466 // so the answer is usage — free, and not a question to the model.
467 let goal = goal_state();
468 let result = run(&goal, None);
469 assert!(!result.is_error);
470 assert!(result.action.is_none());
471 assert!(result.message.unwrap().contains("/goal <objective>"));
472 }
473
474 #[test]
475 fn goal_status_reports_objective_and_state() {
476 let mut goal = goal_state();
477 goal.objective = Some("Make the suite green".to_string());
478 goal.token_budget = Some(100);
479 goal.status = ProjectGoalStatus::Active;
480 let result = run(&goal, Some("status"));
481 let line = result.message.unwrap();
482 assert!(line.contains("Make the suite green"));
483 assert!(line.contains("active"));
484 assert!(line.contains("100"));
485 }
486
487 #[test]
488 fn goal_status_includes_elapsed_and_continuations() {
489 let mut goal = goal_state();
490 goal.objective = Some("Ship it".to_string());
491 goal.status = ProjectGoalStatus::Active;
492 goal.time_used_seconds = 125;
493 goal.continuation_count = 3;
494 let result = run(&goal, Some("status"));
495 let line = result.message.unwrap();
496 assert!(line.contains("elapsed 2m 05s"), "line: {line}");
497 assert!(line.contains("continuations 3"), "line: {line}");
498 }
499
500 #[test]
501 fn paused_goal_status_shows_reason() {
502 let mut goal = goal_state();
503 goal.objective = Some("Ship it".to_string());
504 goal.status = ProjectGoalStatus::Paused;
505 goal.pause_reason = Some("usage limit".to_string());
506 let result = run(&goal, Some("status"));
507 assert!(result.message.unwrap().contains("paused (usage limit)"));
508 }
509
510 #[test]
511 fn resume_on_an_active_goal_is_a_no_op_report() {
512 // Re-asserting Active while the loop is already running must not
513 // schedule a second autonomous turn.
514 let mut goal = goal_state();
515 goal.objective = Some("Keep the build green".to_string());
516 goal.status = ProjectGoalStatus::Active;
517 let resumed = run(&goal, Some("resume"));
518 assert!(!resumed.is_error);
519 assert!(
520 resumed.action.is_none(),
521 "no control op on already-active goal"
522 );
523 assert!(resumed.message.unwrap().contains("Keep the build green"));
524 }
525
526 #[test]
527 fn invalid_budget_suffix_stays_part_of_the_objective() {
528 let goal = goal_state();
529 let result = run(&goal, Some("Fix budget: handling in settings"));
530 assert!(matches!(
531 result.action,
532 Some(AppAction::SetGoalObjective { ref objective, token_budget: None })
533 if objective == "Fix budget: handling in settings"
534 ));
535 }
536
537 #[test]
538 fn completing_a_goal_without_one_is_an_error() {
539 let goal = goal_state();
540 let result = run(&goal, Some("done"));
541 assert!(result.is_error);
542 }
543
544 #[test]
545 fn goal_help_route_returns_usage() {
546 let goal = goal_state();
547 for arg in ["help", "?", "usage"] {
548 let result = run(&goal, Some(arg));
549 assert!(!result.is_error);
550 assert!(result.message.unwrap().contains("/goal <objective>"));
551 }
552 }
553
554 #[test]
555 fn missing_project_facet_fails_safely() {
556 let result = goal_contextual(CommandContexts::empty(), Some("status"));
557 assert!(result.is_error);
558 assert!(
559 result
560 .message
561 .unwrap()
562 .contains("Command capability unavailable: project")
563 );
564 }
565
566 #[test]
567 fn missing_presentation_facet_fails_safely() {
568 // PROJECT present but PRESENTATION absent: safe error, no partial action.
569 let mut project = FakeProject::new();
570 let contexts = CommandContexts::empty().with_project(&mut project);
571 let result = goal_contextual(contexts, Some("status"));
572 assert!(result.is_error);
573 assert!(
574 result.action.is_none(),
575 "no partial action on missing facet"
576 );
577 assert!(
578 result
579 .message
580 .unwrap()
581 .contains("Command capability unavailable: presentation")
582 );
583 }
584
585 #[test]
586 fn format_elapsed_matches_tui_leaf_helper() {
587 // The portable replica must stay byte-identical to the TUI elapsed
588 // helper (Phase 4 review recommendation).
589 for secs in [0, 1, 59, 60, 61, 125, 3599, 3600, 3601] {
590 assert_eq!(
591 format_elapsed(secs),
592 crate::elapsed::format_elapsed_secs(secs),
593 "format_elapsed({secs}) must equal the TUI helper"
594 );
595 }
596 }
597
598 #[test]
599 fn goal_control_accepted_translates_through_fake_presentation() {
600 // Pending controls route to the translated message, not the raw key.
601 let mut goal = goal_state();
602 goal.objective = Some("Keep the build green".to_string());
603 goal.status = ProjectGoalStatus::Active;
604 goal.pending_controls = true;
605 goal.last_known_objective = Some("Keep the build green".to_string());
606 goal.last_known_status = Some(ProjectGoalStatus::Paused);
607 let result = run(&goal, Some("pause"));
608 assert!(!result.is_error);
609 let msg = result.message.expect("pending-control message");
610 assert!(msg.contains("Goal control saved"));
611 assert!(
612 !msg.contains("goal_control_accepted"),
613 "raw key must not leak"
614 );
615 }
616
617 #[test]
618 fn idle_hint_translates_through_fake_presentation() {
619 // Active goal not being driven: the idle hint is the translated text.
620 let mut goal = goal_state();
621 goal.objective = Some("Ship it".to_string());
622 goal.status = ProjectGoalStatus::Active;
623 goal.is_loading = false;
624 goal.goal_continuation_waiting = false;
625 let result = run(&goal, Some("status"));
626 let line = result.message.unwrap();
627 assert!(
628 line.contains("not running now"),
629 "idle hint must be translated: {line}"
630 );
631 assert!(
632 !line.contains("goal_status_idle_hint"),
633 "raw key must not leak"
634 );
635 }
636
637 #[test]
638 fn goal_token_fallback_uses_session_total() {
639 // tokens_used == 0 falls back to the session conversation-token total.
640 let mut goal = goal_state();
641 goal.objective = Some("Ship it".to_string());
642 goal.token_budget = Some(100);
643 goal.tokens_used = 0;
644 goal.session_total_tokens = 42;
645 let result = run(&goal, Some("status"));
646 let line = result.message.unwrap();
647 assert!(line.contains("tokens 42/100 (42%)"), "line: {line}");
648 }
649
650 #[test]
651 fn goal_token_uses_engine_count_when_nonzero() {
652 let mut goal = goal_state();
653 goal.objective = Some("Ship it".to_string());
654 goal.token_budget = Some(100);
655 goal.tokens_used = 10;
656 goal.session_total_tokens = 42;
657 let result = run(&goal, Some("status"));
658 let line = result.message.unwrap();
659 assert!(line.contains("tokens 10/100 (10%)"), "line: {line}");
660 }
661 }
662
662 lines RUST