| 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 |