| 1 | //! Slash command registry and dispatch system |
| 2 | //! |
| 3 | //! This module provides a modular command system inspired by Codex-rs. |
| 4 | //! Commands are organized by category and dispatched through a central strategy |
| 5 | //! registry. Built-in handlers live in group-owned areas under [`groups`]; this |
| 6 | //! module keeps registry construction, user-command precedence, and the |
| 7 | //! fall-through behaviour. |
| 8 | |
| 9 | mod contract; |
| 10 | pub mod discovery; |
| 11 | mod groups; |
| 12 | |
| 13 | // FEAT-025 host services for the session-export slice: the shared recovery |
| 14 | // writer and the protected export-destination resolver/writer. Declared at the |
| 15 | // `commands` root so they stay outside `groups/session`, which FEAT-043 moves |
| 16 | // to `codewhale-commands`. |
| 17 | mod session_export_host; |
| 18 | pub mod traits; |
| 19 | pub mod user_commands; |
| 20 | pub mod user_registry; |
| 21 | |
| 22 | #[cfg(test)] |
| 23 | #[path = "epic_dispatch_acceptance.rs"] |
| 24 | mod epic_dispatch_acceptance; |
| 25 | |
| 26 | #[cfg(test)] |
| 27 | #[path = "epic_discovery_acceptance.rs"] |
| 28 | mod epic_discovery_acceptance; |
| 29 | |
| 30 | // TUI-hosted session acceptance and persistence regressions deliberately stay |
| 31 | // outside `groups/session`, which FEAT-043 moves to `codewhale-commands`. |
| 32 | #[cfg(all(test, feature = "long-running-tests"))] |
| 33 | mod session_acceptance; |
| 34 | #[cfg(test)] |
| 35 | mod session_control_regression_tests; |
| 36 | #[cfg(test)] |
| 37 | mod session_export_regression_tests; |
| 38 | // FEAT-025 Phase 5: public command-surface parity lives at the `commands` root |
| 39 | // for the same extraction reason as the host regressions above. |
| 40 | #[cfg(test)] |
| 41 | mod session_export_surface_tests; |
| 42 | // FEAT-025 audit hardening: shared host-bound test support for both export |
| 43 | // test suites (timestamp normalisation and the exhaustive envelope check). |
| 44 | #[cfg(test)] |
| 45 | mod session_export_test_support; |
| 46 | #[cfg(test)] |
| 47 | mod session_lifecycle_regression_tests; |
| 48 | |
| 49 | use std::sync::OnceLock; |
| 50 | |
| 51 | pub use traits::CommandInfo; |
| 52 | |
| 53 | // Long-standing public paths that predate the group layout. |
| 54 | /// `/fleet add` and the picker's ⇧F share these gates; the UI applies them |
| 55 | /// against the live `Config`. |
| 56 | pub(crate) use groups::core::fleet::{fleet_catalog_rejection, fleet_provider_rejection}; |
| 57 | pub use groups::project::share; |
| 58 | |
| 59 | // Voice capture plumbing shared with the hotbar and the UI event loop. |
| 60 | pub use groups::core::voice; |
| 61 | |
| 62 | use crate::tui::app::{App, AppAction}; |
| 63 | use codewhale_config::AppMode; |
| 64 | |
| 65 | /// Result of executing a command |
| 66 | #[derive(Debug, Clone)] |
| 67 | pub struct CommandResult { |
| 68 | /// Optional message to display to the user |
| 69 | pub message: Option<String>, |
| 70 | /// Optional action for the app to take |
| 71 | pub action: Option<AppAction>, |
| 72 | /// Whether the command failed. |
| 73 | pub is_error: bool, |
| 74 | } |
| 75 | |
| 76 | impl CommandResult { |
| 77 | /// Create an empty result (command succeeded with no output) |
| 78 | pub fn ok() -> Self { |
| 79 | Self { |
| 80 | message: None, |
| 81 | action: None, |
| 82 | is_error: false, |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /// Create a result with just a message |
| 87 | pub fn message(msg: impl Into<String>) -> Self { |
| 88 | Self { |
| 89 | message: Some(msg.into()), |
| 90 | action: None, |
| 91 | is_error: false, |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | /// Create a result with an action |
| 96 | pub fn action(action: AppAction) -> Self { |
| 97 | Self { |
| 98 | message: None, |
| 99 | action: Some(action), |
| 100 | is_error: false, |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// Create a result with both message and action |
| 105 | pub fn with_message_and_action(msg: impl Into<String>, action: AppAction) -> Self { |
| 106 | Self { |
| 107 | message: Some(msg.into()), |
| 108 | action: Some(action), |
| 109 | is_error: false, |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | /// Create an error message result |
| 114 | pub fn error(msg: impl Into<String>) -> Self { |
| 115 | Self { |
| 116 | message: Some(format!("Error: {}", msg.into())), |
| 117 | action: None, |
| 118 | is_error: true, |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | static REGISTRY: OnceLock<traits::CommandRegistry> = OnceLock::new(); |
| 124 | |
| 125 | fn build_registry() -> traits::CommandRegistry { |
| 126 | let mut registry = traits::CommandRegistry::empty(); |
| 127 | for &group in groups::all_command_groups() { |
| 128 | registry.register_group(group); |
| 129 | } |
| 130 | #[cfg(test)] |
| 131 | { |
| 132 | registry.register_test_only(feat015_ctx_command()); |
| 133 | } |
| 134 | registry |
| 135 | } |
| 136 | |
| 137 | /// FEAT-015 test-only contextual command (D6). |
| 138 | /// |
| 139 | /// Registered into the global registry only in test builds; the production |
| 140 | /// registry is untouched. The command implements the portable contract |
| 141 | /// `RegisterCommand` shape, and its handler cannot name concrete `App`; the |
| 142 | /// TUI bridge resolves metadata and dispatches it through public `execute()`. |
| 143 | #[cfg(test)] |
| 144 | struct Feat015TestCommand; |
| 145 | |
| 146 | #[cfg(test)] |
| 147 | impl codewhale_command_contract::metadata::RegisterCommand<CommandResult> for Feat015TestCommand { |
| 148 | fn info() -> &'static codewhale_command_contract::metadata::CommandInfo { |
| 149 | static INFO: codewhale_command_contract::metadata::CommandInfo = |
| 150 | codewhale_command_contract::metadata::CommandInfo { |
| 151 | name: "feat015ctx", |
| 152 | aliases: &[], |
| 153 | usage: "/feat015ctx", |
| 154 | description_key: "cmd_workspace_description", |
| 155 | }; |
| 156 | &INFO |
| 157 | } |
| 158 | |
| 159 | fn handler() -> codewhale_command_contract::handler::CommandHandler<CommandResult> { |
| 160 | codewhale_command_contract::handler::CommandHandler::Contextual { |
| 161 | capabilities: codewhale_command_contract::handler::CommandCapabilities::WORKSPACE |
| 162 | .union(codewhale_command_contract::handler::CommandCapabilities::MODE_POLICY) |
| 163 | .union(codewhale_command_contract::handler::CommandCapabilities::COST), |
| 164 | handler: feat015_contextual, |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | /// Test-only contextual handler: reads workspace, mode, and currency facets |
| 170 | /// through the envelope and returns the host-selected result type. It has no |
| 171 | /// concrete `App` parameter or TUI state in its input surface. |
| 172 | #[cfg(test)] |
| 173 | fn feat015_contextual( |
| 174 | contexts: codewhale_command_contract::handler::CommandContexts<'_>, |
| 175 | arg: Option<&str>, |
| 176 | ) -> CommandResult { |
| 177 | use codewhale_command_contract::handler::ContextParts; |
| 178 | let parts: ContextParts<'_> = contexts.into_parts(); |
| 179 | let Some(workspace) = parts.workspace else { |
| 180 | return CommandResult::error("Command capability unavailable: workspace"); |
| 181 | }; |
| 182 | let Some(mode_policy) = parts.mode_policy else { |
| 183 | return CommandResult::error("Command capability unavailable: mode-policy"); |
| 184 | }; |
| 185 | let Some(cost) = parts.cost else { |
| 186 | return CommandResult::error("Command capability unavailable: cost"); |
| 187 | }; |
| 188 | let workspace = workspace.workspace(); |
| 189 | let mode = mode_policy.mode(); |
| 190 | let currency = cost.display_currency(); |
| 191 | let normalized = arg.unwrap_or(""); |
| 192 | CommandResult::message(format!( |
| 193 | "feat015ctx workspace={} mode={:?} currency={:?} arg={}", |
| 194 | workspace.display(), |
| 195 | mode, |
| 196 | currency, |
| 197 | normalized |
| 198 | )) |
| 199 | } |
| 200 | |
| 201 | #[cfg(test)] |
| 202 | static FEAT015_CTX: OnceLock<&'static traits::ContextualCommand> = OnceLock::new(); |
| 203 | |
| 204 | #[cfg(test)] |
| 205 | fn feat015_ctx_command() -> &'static traits::ContextualCommand { |
| 206 | FEAT015_CTX.get_or_init(|| { |
| 207 | Box::leak(Box::new( |
| 208 | traits::ContextualCommand::from_contract::<Feat015TestCommand>() |
| 209 | .expect("FEAT-015 portable registration must bridge into the TUI registry"), |
| 210 | )) |
| 211 | }) |
| 212 | } |
| 213 | |
| 214 | pub fn registry() -> &'static traits::CommandRegistry { |
| 215 | REGISTRY.get_or_init(build_registry) |
| 216 | } |
| 217 | |
| 218 | pub fn command_infos() -> Vec<&'static CommandInfo> { |
| 219 | registry().infos() |
| 220 | } |
| 221 | |
| 222 | pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> { |
| 223 | registry().get_info(name) |
| 224 | } |
| 225 | |
| 226 | /// Execute a slash command |
| 227 | pub fn execute(cmd: &str, app: &mut App) -> CommandResult { |
| 228 | // Keep the command's raw remainder available for commands whose payload is |
| 229 | // byte-sensitive. Most slash commands intentionally receive a normalized |
| 230 | // argument below; `/preview-request --prompt`, however, must describe the |
| 231 | // exact prompt the send path would receive, including trailing whitespace |
| 232 | // and newlines. |
| 233 | let dispatch_input = cmd.trim_start(); |
| 234 | let command_token_end = dispatch_input |
| 235 | .find(char::is_whitespace) |
| 236 | .unwrap_or(dispatch_input.len()); |
| 237 | let raw_remainder = &dispatch_input[command_token_end..]; |
| 238 | let trimmed = cmd.trim(); |
| 239 | |
| 240 | // `$skillname` is a backward-compatible alias for `/skill skillname`. |
| 241 | // Resolve it early so skills can be loaded with the `$` prefix. |
| 242 | if let Some(skill_input) = trimmed.strip_prefix('$') { |
| 243 | let skill_input = skill_input.trim_start(); |
| 244 | if skill_input.is_empty() { |
| 245 | return CommandResult::error( |
| 246 | "Type a skill name after $. For example: $getting-started", |
| 247 | ); |
| 248 | } |
| 249 | let parts: Vec<&str> = skill_input.splitn(2, char::is_whitespace).collect(); |
| 250 | let skill_name = parts.first().copied().unwrap_or(""); |
| 251 | let arg = parts |
| 252 | .get(1) |
| 253 | .map(|value| value.trim()) |
| 254 | .filter(|value| !value.is_empty()); |
| 255 | if let Some(result) = groups::skills::run_skill_by_name(app, skill_name, arg) { |
| 256 | return result; |
| 257 | } |
| 258 | return CommandResult::error(format!( |
| 259 | "Unknown skill: ${skill_name}. Type /skills to see installed skills." |
| 260 | )); |
| 261 | } |
| 262 | |
| 263 | let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect(); |
| 264 | let command = parts |
| 265 | .first() |
| 266 | .copied() |
| 267 | .unwrap_or_default() |
| 268 | .trim_start_matches('/') |
| 269 | .to_ascii_lowercase(); |
| 270 | let arg = parts |
| 271 | .get(1) |
| 272 | .map(|value| value.trim()) |
| 273 | .filter(|value| !value.is_empty()); |
| 274 | |
| 275 | // Check user-defined commands FIRST so they can override built-ins. |
| 276 | if let Some(result) = user_registry::try_dispatch(app, trimmed) { |
| 277 | return result; |
| 278 | } |
| 279 | |
| 280 | // Permanent backward-compatible mode aliases. They select a fixed mode |
| 281 | // rather than the canonical `/mode` behavior, so they still dispatch |
| 282 | // before registry lookup. Ordinary compatibility aliases belong in their |
| 283 | // command's `CommandInfo` metadata. |
| 284 | match command.as_str() { |
| 285 | "jihua" => { |
| 286 | return groups::config::dispatch(app, "jihua", arg).unwrap_or_else(|| { |
| 287 | CommandResult::error("The /jihua alias could not be dispatched.") |
| 288 | }); |
| 289 | } |
| 290 | "zidong" => { |
| 291 | return groups::config::dispatch(app, "zidong", arg).unwrap_or_else(|| { |
| 292 | CommandResult::error("The /zidong alias could not be dispatched.") |
| 293 | }); |
| 294 | } |
| 295 | _ => {} |
| 296 | } |
| 297 | |
| 298 | if let Some(command_object) = registry().get(command.as_str()) { |
| 299 | let command_arg = if command_object.info().name == "preview-request" { |
| 300 | Some(raw_remainder) |
| 301 | } else { |
| 302 | arg |
| 303 | }; |
| 304 | // FEAT-015 dual-path seam (D2): a migrated entry with a |
| 305 | // capability-scoped handler receives the envelope built from `app`; |
| 306 | // everything else keeps the legacy `execute(app, args)` path. The |
| 307 | // envelope is populated only with the capabilities the registration |
| 308 | // declared (FEAT-019 D1/D3); production groups such as utility and |
| 309 | // memory dispatch through this contextual branch. |
| 310 | if let Some(handler) = command_object.contextual_handler() { |
| 311 | return match handler { |
| 312 | codewhale_command_contract::handler::CommandHandler::Pure(pure_fn) => { |
| 313 | pure_fn(command_arg) |
| 314 | } |
| 315 | codewhale_command_contract::handler::CommandHandler::Contextual { |
| 316 | capabilities, |
| 317 | handler: contextual, |
| 318 | } => { |
| 319 | let mut bundle = app.command_contexts(); |
| 320 | contextual(bundle.contexts(capabilities), command_arg) |
| 321 | } |
| 322 | }; |
| 323 | } |
| 324 | return command_object.execute(app, command_arg); |
| 325 | } |
| 326 | |
| 327 | match command.as_str() { |
| 328 | // Permanent legacy migration hints. These are deliberately excluded |
| 329 | // from registry/autocomplete and only appear when users type old names. |
| 330 | "set" => CommandResult::error( |
| 331 | "The /set command was retired. Use /config to edit settings and /settings to inspect current values.", |
| 332 | ), |
| 333 | "deepseek" => CommandResult::error( |
| 334 | "The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).", |
| 335 | ), |
| 336 | "doctor" => CommandResult::error( |
| 337 | "The /doctor command is a CLI diagnostic. Run `codewhale doctor` or `codewhale doctor --json`; use `/setup` in the TUI for readiness and verification.", |
| 338 | ), |
| 339 | |
| 340 | _ => { |
| 341 | // Third source: skills (lowest precedence after native and user-config). |
| 342 | // Try to run a skill whose name matches the command. |
| 343 | if let Some(result) = groups::skills::run_skill_by_name(app, command.as_str(), arg) { |
| 344 | return result; |
| 345 | } |
| 346 | let suggestions = |
| 347 | user_registry::with_registry_for_workspace(Some(&app.workspace), |user_commands| { |
| 348 | suggest_command_names(command.as_str(), 3, user_commands) |
| 349 | }); |
| 350 | if suggestions.is_empty() { |
| 351 | CommandResult::error(format!( |
| 352 | "Unknown command: /{command}. Type /help for available commands." |
| 353 | )) |
| 354 | } else { |
| 355 | let list = suggestions |
| 356 | .into_iter() |
| 357 | .map(|name| format!("/{name}")) |
| 358 | .collect::<Vec<_>>() |
| 359 | .join(", "); |
| 360 | CommandResult::error(format!( |
| 361 | "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands." |
| 362 | )) |
| 363 | } |
| 364 | } |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | /// Update a configuration value programmatically (used by interactive UI views). |
| 369 | pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { |
| 370 | groups::config::config::set_config_value(app, key, value, persist) |
| 371 | } |
| 372 | |
| 373 | /// Switch the interaction mode (plan / work / operate). |
| 374 | pub fn switch_mode(app: &mut App, mode: AppMode) -> String { |
| 375 | groups::config::config::switch_mode(app, mode) |
| 376 | } |
| 377 | |
| 378 | fn edit_distance(a: &str, b: &str) -> usize { |
| 379 | if a == b { |
| 380 | return 0; |
| 381 | } |
| 382 | if a.is_empty() { |
| 383 | return b.chars().count(); |
| 384 | } |
| 385 | if b.is_empty() { |
| 386 | return a.chars().count(); |
| 387 | } |
| 388 | |
| 389 | let b_chars: Vec<char> = b.chars().collect(); |
| 390 | let mut previous: Vec<usize> = (0..=b_chars.len()).collect(); |
| 391 | let mut current = vec![0usize; b_chars.len() + 1]; |
| 392 | |
| 393 | for (i, a_ch) in a.chars().enumerate() { |
| 394 | current[0] = i + 1; |
| 395 | for (j, b_ch) in b_chars.iter().enumerate() { |
| 396 | let cost = if a_ch == *b_ch { 0 } else { 1 }; |
| 397 | let delete = previous[j + 1] + 1; |
| 398 | let insert = current[j] + 1; |
| 399 | let substitute = previous[j] + cost; |
| 400 | current[j + 1] = delete.min(insert).min(substitute); |
| 401 | } |
| 402 | std::mem::swap(&mut previous, &mut current); |
| 403 | } |
| 404 | |
| 405 | previous[b_chars.len()] |
| 406 | } |
| 407 | |
| 408 | fn best_suggestion_score<'a>( |
| 409 | query: &str, |
| 410 | candidates: impl IntoIterator<Item = &'a str>, |
| 411 | ) -> Option<(u8, usize)> { |
| 412 | let mut best: Option<(u8, usize)> = None; |
| 413 | for candidate in candidates { |
| 414 | let prefix_match = candidate.starts_with(query) || query.starts_with(candidate); |
| 415 | let contains_match = candidate.contains(query) || query.contains(candidate); |
| 416 | let distance = edit_distance(candidate, query); |
| 417 | let close_typo = distance <= 2; |
| 418 | if !(prefix_match || contains_match || close_typo) { |
| 419 | continue; |
| 420 | } |
| 421 | |
| 422 | let rank = if prefix_match { |
| 423 | 0 |
| 424 | } else if contains_match { |
| 425 | 1 |
| 426 | } else { |
| 427 | 2 |
| 428 | }; |
| 429 | |
| 430 | match best { |
| 431 | Some((best_rank, best_distance)) |
| 432 | if rank > best_rank || (rank == best_rank && distance >= best_distance) => {} |
| 433 | _ => best = Some((rank, distance)), |
| 434 | } |
| 435 | } |
| 436 | best |
| 437 | } |
| 438 | |
| 439 | fn suggest_command_names( |
| 440 | input: &str, |
| 441 | limit: usize, |
| 442 | user_commands: &user_registry::UserCommandRegistry, |
| 443 | ) -> Vec<String> { |
| 444 | let query = input.trim().to_ascii_lowercase(); |
| 445 | if query.is_empty() || limit == 0 { |
| 446 | return Vec::new(); |
| 447 | } |
| 448 | |
| 449 | let mut scored: Vec<(u8, usize, String)> = Vec::new(); |
| 450 | for command in registry().infos() { |
| 451 | // A user command can shadow a built-in canonical name or just one of |
| 452 | // its aliases. Score only the built-in spellings that still dispatch |
| 453 | // to the built-in so suggestions never advertise different behavior. |
| 454 | if user_commands.get(command.name).is_some() { |
| 455 | continue; |
| 456 | } |
| 457 | let candidates = std::iter::once(command.name).chain( |
| 458 | command |
| 459 | .aliases |
| 460 | .iter() |
| 461 | .copied() |
| 462 | .filter(|alias| user_commands.get(alias).is_none()), |
| 463 | ); |
| 464 | if let Some((rank, distance)) = best_suggestion_score(&query, candidates) { |
| 465 | scored.push((rank, distance, command.name.to_string())); |
| 466 | } |
| 467 | } |
| 468 | |
| 469 | for command in user_commands.iter().filter(|command| !command.hidden) { |
| 470 | let candidates = std::iter::once(command.name.as_str()).chain( |
| 471 | command.aliases.iter().map(String::as_str).filter(|alias| { |
| 472 | user_commands |
| 473 | .get(alias) |
| 474 | .is_some_and(|resolved| resolved.name == command.name) |
| 475 | }), |
| 476 | ); |
| 477 | if let Some((rank, distance)) = best_suggestion_score(&query, candidates) { |
| 478 | scored.push((rank, distance, command.name.clone())); |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | scored.sort_by(|a, b| { |
| 483 | a.0.cmp(&b.0) |
| 484 | .then_with(|| a.1.cmp(&b.1)) |
| 485 | .then_with(|| a.2.cmp(&b.2)) |
| 486 | }); |
| 487 | scored |
| 488 | .into_iter() |
| 489 | .take(limit) |
| 490 | .map(|(_, _, name)| name) |
| 491 | .collect() |
| 492 | } |
| 493 | |
| 494 | #[cfg(test)] |
| 495 | mod tests { |
| 496 | use super::*; |
| 497 | use crate::config::{ApiProvider, Config}; |
| 498 | use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; |
| 499 | use crate::tools::todo::TodoStatus; |
| 500 | use crate::tui::app::{App, AppAction, TuiOptions}; |
| 501 | use crate::tui::work_surface::{RailPanel, WorkSurfacePlacement}; |
| 502 | use codewhale_localization::{Locale, MessageId}; |
| 503 | use std::ffi::OsString; |
| 504 | use std::path::{Path, PathBuf}; |
| 505 | use tempfile::tempdir; |
| 506 | |
| 507 | fn is_palette_safe_command_name(name: &str) -> bool { |
| 508 | let bytes = name.as_bytes(); |
| 509 | !bytes.is_empty() |
| 510 | && bytes.first().is_some_and(u8::is_ascii_alphanumeric) |
| 511 | && bytes.last().is_some_and(u8::is_ascii_alphanumeric) |
| 512 | && bytes |
| 513 | .iter() |
| 514 | .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') |
| 515 | && !name.contains("--") |
| 516 | } |
| 517 | |
| 518 | fn create_test_app() -> App { |
| 519 | let options = TuiOptions { |
| 520 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 521 | }; |
| 522 | App::new(options, &Config::default()) |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn user_registry_module_is_compiled() { |
| 527 | super::user_registry::reload(None); |
| 528 | let registry = super::user_registry::current_registry(); |
| 529 | assert!(registry.is_valid()); |
| 530 | } |
| 531 | |
| 532 | #[test] |
| 533 | fn preview_request_dispatch_preserves_prompt_edge_bytes() { |
| 534 | let mut app = create_test_app(); |
| 535 | let result = execute("/preview-request --prompt lead\ntrail ", &mut app); |
| 536 | |
| 537 | assert!(!result.is_error, "{result:?}"); |
| 538 | assert!(matches!( |
| 539 | result.action, |
| 540 | Some(AppAction::PreviewOutboundRequest { |
| 541 | json: false, |
| 542 | base_prompt_only: false, |
| 543 | hypothetical_prompt, |
| 544 | }) if hypothetical_prompt.as_deref() == Some(" lead\ntrail ") |
| 545 | )); |
| 546 | } |
| 547 | |
| 548 | #[test] |
| 549 | fn user_command_shadows_builtin_before_group_dispatch() { |
| 550 | let temp = tempdir().unwrap(); |
| 551 | let commands_dir = temp.path().join(".codewhale").join("commands"); |
| 552 | std::fs::create_dir_all(&commands_dir).unwrap(); |
| 553 | std::fs::write( |
| 554 | commands_dir.join("help.md"), |
| 555 | "---\ndescription: User help\n---\nuser help $ARGUMENTS", |
| 556 | ) |
| 557 | .unwrap(); |
| 558 | |
| 559 | let mut app = create_test_app(); |
| 560 | app.workspace = temp.path().to_path_buf(); |
| 561 | super::user_registry::reload(Some(temp.path())); |
| 562 | |
| 563 | let result = execute("/help now", &mut app); |
| 564 | assert!(!result.is_error); |
| 565 | match result.action { |
| 566 | Some(AppAction::SendMessage(message)) => assert_eq!(message, "user help now"), |
| 567 | other => panic!("expected user command SendMessage action, got {other:?}"), |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | #[test] |
| 572 | fn removed_user_command_reloads_and_falls_back_to_builtin() { |
| 573 | let temp = tempdir().unwrap(); |
| 574 | let commands_dir = temp.path().join(".codewhale").join("commands"); |
| 575 | std::fs::create_dir_all(&commands_dir).unwrap(); |
| 576 | let command_path = commands_dir.join("help.md"); |
| 577 | std::fs::write(&command_path, "user help").unwrap(); |
| 578 | |
| 579 | let mut app = create_test_app(); |
| 580 | app.workspace = temp.path().to_path_buf(); |
| 581 | super::user_registry::reload(Some(temp.path())); |
| 582 | assert!(matches!( |
| 583 | execute("/help config", &mut app).action, |
| 584 | Some(AppAction::SendMessage(_)) |
| 585 | )); |
| 586 | |
| 587 | std::fs::remove_file(command_path).unwrap(); |
| 588 | super::user_registry::reload(Some(temp.path())); |
| 589 | let result = execute("/help config", &mut app); |
| 590 | assert!(!result.is_error); |
| 591 | assert!( |
| 592 | result |
| 593 | .message |
| 594 | .as_deref() |
| 595 | .is_some_and(|message| message.contains("config")), |
| 596 | "built-in /help should handle the command" |
| 597 | ); |
| 598 | assert!(result.action.is_none()); |
| 599 | } |
| 600 | |
| 601 | #[test] |
| 602 | fn command_registry_contains_config_and_links_but_not_set_or_deepseek() { |
| 603 | assert!(command_infos().iter().any(|cmd| cmd.name == "config")); |
| 604 | assert!(get_command_info("experiments").is_none()); |
| 605 | assert!(get_command_info("experimental").is_none()); |
| 606 | let rail = command_infos() |
| 607 | .into_iter() |
| 608 | .find(|cmd| cmd.name == "workbar") |
| 609 | .expect("workbar command should exist"); |
| 610 | assert_eq!(rail.aliases, &["rail", "sidebar"]); |
| 611 | assert_eq!(rail.description_id, MessageId::CmdSidebarDescription); |
| 612 | assert!(rail.description_for(Locale::En).contains("workbar")); |
| 613 | assert!(command_infos().iter().any(|cmd| cmd.name == "links")); |
| 614 | let hf = command_infos() |
| 615 | .into_iter() |
| 616 | .find(|cmd| cmd.name == "hf") |
| 617 | .expect("hf command should exist"); |
| 618 | assert_eq!(hf.aliases, &["huggingface"]); |
| 619 | assert_eq!(hf.description_id, MessageId::CmdHfDescription); |
| 620 | assert!(hf.description_for(Locale::En).contains("Hugging Face")); |
| 621 | assert!(command_infos().iter().any(|cmd| cmd.name == "memory")); |
| 622 | assert!(!command_infos().iter().any(|cmd| cmd.name == "set")); |
| 623 | assert!(!command_infos().iter().any(|cmd| cmd.name == "deepseek")); |
| 624 | } |
| 625 | |
| 626 | #[test] |
| 627 | fn pet_command_is_registered_and_the_workbar_no_longer_advertises_watch() { |
| 628 | let pet = command_infos() |
| 629 | .into_iter() |
| 630 | .find(|cmd| cmd.name == "pet") |
| 631 | .expect("pet command should exist"); |
| 632 | assert_eq!(pet.description_id, MessageId::CmdPetDescription); |
| 633 | assert!(pet.usage.starts_with("/pet")); |
| 634 | let rail = command_infos() |
| 635 | .into_iter() |
| 636 | .find(|cmd| cmd.name == "workbar") |
| 637 | .expect("workbar command should exist"); |
| 638 | assert!(!rail.usage.contains("watch"), "{}", rail.usage); |
| 639 | } |
| 640 | |
| 641 | #[test] |
| 642 | fn links_command_has_dashboard_and_api_aliases() { |
| 643 | let links = command_infos() |
| 644 | .into_iter() |
| 645 | .find(|cmd| cmd.name == "links") |
| 646 | .expect("links command should exist"); |
| 647 | assert_eq!(links.aliases, &["dashboard", "api", "lianjie"]); |
| 648 | } |
| 649 | |
| 650 | #[test] |
| 651 | fn transcript_command_is_discoverable_and_opens_live_overlay() { |
| 652 | let transcript = command_infos() |
| 653 | .into_iter() |
| 654 | .find(|cmd| cmd.name == "transcript") |
| 655 | .expect("transcript command should exist"); |
| 656 | assert_eq!(transcript.usage, "/transcript"); |
| 657 | assert!(transcript.show_in_empty_discovery()); |
| 658 | |
| 659 | let mut app = create_test_app(); |
| 660 | let result = execute("/transcript", &mut app); |
| 661 | assert!(!result.is_error); |
| 662 | assert!(matches!(result.action, Some(AppAction::OpenLiveTranscript))); |
| 663 | } |
| 664 | |
| 665 | #[test] |
| 666 | fn hf_alias_dispatches_to_concepts_helper() { |
| 667 | let mut app = create_test_app(); |
| 668 | let result = execute("/huggingface concepts", &mut app); |
| 669 | assert!(!result.is_error); |
| 670 | let message = result.message.expect("concepts message"); |
| 671 | assert!(message.contains("Hugging Face provider route")); |
| 672 | assert!(message.contains("Hugging Face MCP")); |
| 673 | assert!(message.contains("Hub workflows")); |
| 674 | } |
| 675 | |
| 676 | #[test] |
| 677 | fn login_slash_command_reports_status_and_key_opens_picker() { |
| 678 | let mut app = create_test_app(); |
| 679 | let status = execute("/login", &mut app); |
| 680 | assert!(!status.is_error); |
| 681 | let message = status.message.expect("login status"); |
| 682 | assert!(message.contains("Codewhale login"), "{message}"); |
| 683 | assert!(message.contains("Account:"), "{message}"); |
| 684 | assert!(message.contains("codewhale login"), "{message}"); |
| 685 | // No-brand invariant: the internal cloud-agent slot is not user |
| 686 | // surface, so status never names it or teaches a set-slot command. |
| 687 | assert!(!message.contains("Daytona"), "{message}"); |
| 688 | assert!(!message.contains("set-slot"), "{message}"); |
| 689 | |
| 690 | let key = execute("/login key", &mut app); |
| 691 | assert!(!key.is_error); |
| 692 | assert_eq!(key.action, Some(AppAction::OpenProviderPicker)); |
| 693 | |
| 694 | let daytona = execute("/login daytona", &mut app); |
| 695 | assert!(daytona.is_error); |
| 696 | let err = daytona.message.expect("usage"); |
| 697 | assert!(err.contains("Usage: /login [status|account|key]"), "{err}"); |
| 698 | |
| 699 | let unknown = execute("/login oauth", &mut app); |
| 700 | assert!(unknown.is_error); |
| 701 | let err = unknown.message.expect("usage"); |
| 702 | assert!(err.contains("Usage: /login"), "{err}"); |
| 703 | } |
| 704 | |
| 705 | #[test] |
| 706 | fn xai_device_auth_slash_command_starts_login() { |
| 707 | let mut app = create_test_app(); |
| 708 | let result = execute("/auth xai-device", &mut app); |
| 709 | assert!(!result.is_error); |
| 710 | assert!(matches!( |
| 711 | result.action, |
| 712 | Some(AppAction::StartXaiDeviceLogin) |
| 713 | )); |
| 714 | } |
| 715 | |
| 716 | #[test] |
| 717 | fn chatgpt_auth_slash_command_starts_login() { |
| 718 | let mut app = create_test_app(); |
| 719 | let result = execute("/auth chatgpt", &mut app); |
| 720 | assert!(!result.is_error); |
| 721 | assert!(matches!( |
| 722 | result.action, |
| 723 | Some(AppAction::StartChatgptPkceLogin) |
| 724 | )); |
| 725 | } |
| 726 | |
| 727 | #[test] |
| 728 | fn chatgpt_revoke_slash_command_defers_to_the_event_loop() { |
| 729 | // The remote revoke is a blocking round trip; the command must hand it |
| 730 | // to the loop instead of doing it inline (#5784 review). |
| 731 | let mut app = create_test_app(); |
| 732 | let result = execute("/auth chatgpt-revoke", &mut app); |
| 733 | assert!(!result.is_error); |
| 734 | assert!(matches!(result.action, Some(AppAction::StartChatgptRevoke))); |
| 735 | } |
| 736 | |
| 737 | #[test] |
| 738 | fn rlm_slash_command_routes_to_persistent_tool_instruction() { |
| 739 | let mut app = create_test_app(); |
| 740 | let result = execute("/rlm 2 inspect this long corpus", &mut app); |
| 741 | assert!(!result.is_error); |
| 742 | assert!( |
| 743 | result |
| 744 | .message |
| 745 | .as_deref() |
| 746 | .unwrap_or("") |
| 747 | .contains("persistent working context") |
| 748 | ); |
| 749 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 750 | panic!("expected SendMessage action"); |
| 751 | }; |
| 752 | assert!(message.contains("session-persistent working context")); |
| 753 | assert!(message.contains("Do not use legacy `rlm` tool actions")); |
| 754 | } |
| 755 | |
| 756 | /// `/kernel` was briefly introduced by an in-flight change and rejected: |
| 757 | /// the persistent working context is ordinary Agent behavior, not a |
| 758 | /// control surface users have to learn. |
| 759 | #[test] |
| 760 | fn kernel_is_not_a_command() { |
| 761 | let mut app = create_test_app(); |
| 762 | let result = execute("/kernel inspect the fresh corpus", &mut app); |
| 763 | assert!( |
| 764 | result.is_error, |
| 765 | "/kernel must not resolve to a registered command" |
| 766 | ); |
| 767 | } |
| 768 | |
| 769 | #[test] |
| 770 | fn agent_slash_command_routes_to_persistent_tool_instruction() { |
| 771 | let mut app = create_test_app(); |
| 772 | let result = execute("/agent 0 inspect the parser", &mut app); |
| 773 | assert!(!result.is_error); |
| 774 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 775 | panic!("expected SendMessage action"); |
| 776 | }; |
| 777 | assert!(message.contains("`agent`")); |
| 778 | assert!(message.contains("max_depth: 0")); |
| 779 | } |
| 780 | |
| 781 | #[test] |
| 782 | fn relay_slash_command_routes_to_session_relay_instruction() { |
| 783 | let mut app = create_test_app(); |
| 784 | app.goal.objective = Some("Unify the work surface".to_string()); |
| 785 | app.goal.token_budget = Some(12_000); |
| 786 | { |
| 787 | let mut todos = app.todos.try_lock().expect("todo lock"); |
| 788 | todos.add("inspect workspace".to_string(), TodoStatus::Completed); |
| 789 | todos.add("patch relay command".to_string(), TodoStatus::InProgress); |
| 790 | } |
| 791 | { |
| 792 | let mut plan = app.plan_state.try_lock().expect("plan lock"); |
| 793 | plan.update(UpdatePlanArgs { |
| 794 | objective: Some("Keep relays grounded".to_string()), |
| 795 | explanation: Some("RLM-style strategy".to_string()), |
| 796 | sources_used: vec!["transcript context".to_string()], |
| 797 | critical_files: vec!["crates/tui/src/commands/mod.rs".to_string()], |
| 798 | constraints: vec!["Do not invent verification".to_string()], |
| 799 | verification_plan: Some("Check relay prompt assertions".to_string()), |
| 800 | handoff_packet: Some("Next thread should read the To-do list".to_string()), |
| 801 | plan: vec![PlanItemArg { |
| 802 | step: "keep To-do primary".to_string(), |
| 803 | status: StepStatus::InProgress, |
| 804 | }], |
| 805 | ..UpdatePlanArgs::default() |
| 806 | }); |
| 807 | } |
| 808 | |
| 809 | let result = execute("/relay verify install", &mut app); |
| 810 | assert!(!result.is_error); |
| 811 | assert!( |
| 812 | result |
| 813 | .message |
| 814 | .as_deref() |
| 815 | .unwrap_or_default() |
| 816 | .contains(".deepseek/handoff.md") |
| 817 | ); |
| 818 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 819 | panic!("expected SendMessage action"); |
| 820 | }; |
| 821 | assert!(message.contains("session relay")); |
| 822 | assert!(message.contains("接力")); |
| 823 | assert!(message.contains("Write or update `.deepseek/handoff.md`")); |
| 824 | assert!(message.contains("# Session relay")); |
| 825 | assert!(message.contains("Requested relay focus: verify install")); |
| 826 | assert!(message.contains("Goal objective: Unify the work surface")); |
| 827 | assert!(message.contains("Goal token budget: 12000")); |
| 828 | // #3983: the relay artifact shows the same bounded To-do snapshot body |
| 829 | // a forked agent is handed — byte for byte. |
| 830 | let expected_body = crate::todo_snapshot::todo_snapshot_body( |
| 831 | &app.todos.try_lock().expect("todo lock").snapshot(), |
| 832 | ) |
| 833 | .expect("canonical body"); |
| 834 | assert_eq!( |
| 835 | expected_body, |
| 836 | "To-do (50% settled)\n- [x] #1 inspect workspace\n- [~] #2 patch relay command" |
| 837 | ); |
| 838 | assert!( |
| 839 | message.contains(&expected_body), |
| 840 | "relay must embed the canonical To-do body: {message}" |
| 841 | ); |
| 842 | assert!(message.contains("Conversational strategy notes from update_plan")); |
| 843 | assert!(message.contains("Objective: Keep relays grounded")); |
| 844 | assert!(message.contains("Explanation: RLM-style strategy")); |
| 845 | assert!(message.contains("Source: transcript context")); |
| 846 | assert!(message.contains("Critical file: crates/tui/src/commands/mod.rs")); |
| 847 | assert!(message.contains("Constraint: Do not invent verification")); |
| 848 | assert!(message.contains("Verification plan: Check relay prompt assertions")); |
| 849 | assert!(message.contains("Handoff packet: Next thread should read the To-do list")); |
| 850 | assert!(message.contains("[in_progress] keep To-do primary")); |
| 851 | assert!( |
| 852 | !message.contains("Work checklist"), |
| 853 | "relay copy should use To-do vocabulary: {message}" |
| 854 | ); |
| 855 | } |
| 856 | |
| 857 | /// #3983: `update_plan` is conversational strategy, not a To-do. A session |
| 858 | /// with plan state and an empty To-do has no list to hand off, and the |
| 859 | /// relay artifact must not manufacture one. |
| 860 | #[test] |
| 861 | fn relay_does_not_present_plan_only_state_as_work_state() { |
| 862 | let mut app = create_test_app(); |
| 863 | { |
| 864 | let mut plan = app.plan_state.try_lock().expect("plan lock"); |
| 865 | plan.update(UpdatePlanArgs { |
| 866 | objective: Some("Ship the To-do seam".to_string()), |
| 867 | plan: vec![PlanItemArg { |
| 868 | step: "draft the renderer".to_string(), |
| 869 | status: StepStatus::InProgress, |
| 870 | }], |
| 871 | ..UpdatePlanArgs::default() |
| 872 | }); |
| 873 | } |
| 874 | |
| 875 | let result = execute("/relay", &mut app); |
| 876 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 877 | panic!("expected SendMessage action"); |
| 878 | }; |
| 879 | |
| 880 | assert!( |
| 881 | !message.contains("Current To-do:"), |
| 882 | "plan-only state must not render as a To-do: {message}" |
| 883 | ); |
| 884 | assert!( |
| 885 | !message.contains("To-do ("), |
| 886 | "plan-only state must not synthesize a To-do list: {message}" |
| 887 | ); |
| 888 | assert!(message.contains("Conversational strategy notes from update_plan")); |
| 889 | } |
| 890 | |
| 891 | /// #3983: a graph-backed update is authoritative immediately, even before |
| 892 | /// the compatibility To-do projection is published to the UI. |
| 893 | #[tokio::test] |
| 894 | async fn relay_reads_same_turn_graph_backed_work_update() { |
| 895 | use crate::tools::spec::ToolSpec as _; |
| 896 | |
| 897 | let mut app = create_test_app(); |
| 898 | let work = |
| 899 | crate::work_graph::new_shared_work_runtime(app.todos.clone(), app.plan_state.clone()); |
| 900 | app.runtime_services.work = Some(work.clone()); |
| 901 | |
| 902 | let mut context = crate::tools::spec::ToolContext::new(app.workspace.clone()); |
| 903 | context.runtime.work = Some(work); |
| 904 | crate::tools::todo::TodoWriteTool::new(app.todos.clone()) |
| 905 | .execute( |
| 906 | serde_json::json!({ |
| 907 | "todos": [{"content": "relay the staged graph", "status": "in_progress"}] |
| 908 | }), |
| 909 | &context, |
| 910 | ) |
| 911 | .await |
| 912 | .expect("graph-backed todo_write"); |
| 913 | |
| 914 | assert!( |
| 915 | app.todos.lock().await.snapshot().is_empty(), |
| 916 | "precondition: legacy projection has not published yet" |
| 917 | ); |
| 918 | |
| 919 | let result = execute("/relay", &mut app); |
| 920 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 921 | panic!("expected SendMessage action"); |
| 922 | }; |
| 923 | assert!( |
| 924 | message.contains("[~] #1 relay the staged graph"), |
| 925 | "{message}" |
| 926 | ); |
| 927 | } |
| 928 | |
| 929 | #[test] |
| 930 | fn relay_command_has_bilingual_aliases() { |
| 931 | let relay = command_infos() |
| 932 | .into_iter() |
| 933 | .find(|cmd| cmd.name == "relay") |
| 934 | .expect("relay command should exist"); |
| 935 | assert_eq!(relay.aliases, &["batonpass", "接力"]); |
| 936 | assert!(relay.description_for(Locale::ZhHans).contains("接力")); |
| 937 | assert!(relay.description_for(Locale::ZhHant).contains("接力")); |
| 938 | |
| 939 | let mut app = create_test_app(); |
| 940 | let result = execute("/接力 next hand", &mut app); |
| 941 | assert!(!result.is_error); |
| 942 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 943 | panic!("expected SendMessage action"); |
| 944 | }; |
| 945 | assert!(message.contains("Requested relay focus: next hand")); |
| 946 | } |
| 947 | |
| 948 | /// AT-008: No built-in command name or alias is registered twice, |
| 949 | /// and no built-in alias collides with another command's canonical name. |
| 950 | /// This test iterates every command from `command_infos()` (all 9 groups) |
| 951 | /// and asserts uniqueness across the full set of names and aliases. |
| 952 | #[test] |
| 953 | fn command_registry_has_unique_names_and_aliases() { |
| 954 | let mut names = std::collections::BTreeSet::new(); |
| 955 | for command in command_infos() { |
| 956 | assert!( |
| 957 | names.insert(command.name), |
| 958 | "duplicate command name /{}", |
| 959 | command.name |
| 960 | ); |
| 961 | } |
| 962 | |
| 963 | let mut aliases = std::collections::BTreeSet::new(); |
| 964 | for command in command_infos() { |
| 965 | for alias in command.aliases { |
| 966 | assert!( |
| 967 | !names.contains(alias), |
| 968 | "alias /{alias} collides with a command name" |
| 969 | ); |
| 970 | assert!(aliases.insert(*alias), "duplicate command alias /{alias}"); |
| 971 | } |
| 972 | } |
| 973 | } |
| 974 | |
| 975 | /// AT-009: Command ownership contract — top-level `commands/mod.rs` only |
| 976 | /// registers groups (`groups::all_command_groups()`), each group owns its |
| 977 | /// `commands()` list, and every command has valid metadata. |
| 978 | /// |
| 979 | /// Config and debug groups are documented permanent exceptions: they keep |
| 980 | /// group-local `CommandInfo` statics and `dispatch()` in `mod.rs` rather |
| 981 | /// than extracting every command into a focused module. This is accepted |
| 982 | /// final structure per FEAT-008 §3.2. |
| 983 | /// |
| 984 | /// Enforcement strategy: |
| 985 | /// - Exactly 9 source-verified groups (from `groups/mod.rs`) |
| 986 | /// - Each group owns its commands() list |
| 987 | /// - Config and debug exceptions verified within their specific groups by |
| 988 | /// identifying the group through its first command ("config" and "tokens") |
| 989 | /// - Not circular: the group-iterated command count is a consistency check; |
| 990 | /// the primary enforcement is exact group count + per-group non-empty + valid metadata |
| 991 | #[test] |
| 992 | fn command_ownership_contract_is_enforced() { |
| 993 | let groups = groups::all_command_groups(); |
| 994 | |
| 995 | // AT-009 primary: exactly 9 groups matching groups/mod.rs |
| 996 | assert_eq!( |
| 997 | groups.len(), |
| 998 | 9, |
| 999 | "expected exactly 9 command groups (core, session, config, debug, \ |
| 1000 | project, skills, memory, plugins, utility), got {}", |
| 1001 | groups.len() |
| 1002 | ); |
| 1003 | |
| 1004 | let mut total_commands = 0; |
| 1005 | let mut has_config = false; |
| 1006 | let mut has_debug = false; |
| 1007 | for &group in groups { |
| 1008 | let commands = group.commands(); |
| 1009 | assert!( |
| 1010 | !commands.is_empty(), |
| 1011 | "each group must have at least one command" |
| 1012 | ); |
| 1013 | for cmd in commands { |
| 1014 | let info = cmd.info(); |
| 1015 | assert!(!info.name.is_empty(), "command name must not be empty"); |
| 1016 | assert!( |
| 1017 | is_palette_safe_command_name(info.name), |
| 1018 | "/{} command names must be lowercase ASCII kebab-case", |
| 1019 | info.name |
| 1020 | ); |
| 1021 | let usage_prefix = format!("/{}", info.name); |
| 1022 | assert!( |
| 1023 | info.usage.starts_with(&usage_prefix), |
| 1024 | "/{} usage must start with /{{name}}, got {:?}", |
| 1025 | info.name, |
| 1026 | info.usage |
| 1027 | ); |
| 1028 | } |
| 1029 | total_commands += commands.len(); |
| 1030 | |
| 1031 | // Identify config and debug groups by their command content to |
| 1032 | // verify permanent-exception counts within the correct group. |
| 1033 | if commands.iter().any(|c| c.info().name == "config") { |
| 1034 | has_config = true; |
| 1035 | assert_eq!( |
| 1036 | commands.len(), |
| 1037 | 17, |
| 1038 | "config group (group-local metadata exception) expected \ |
| 1039 | exactly 17 commands, got {}", |
| 1040 | commands.len() |
| 1041 | ); |
| 1042 | } |
| 1043 | if commands.iter().any(|c| c.info().name == "tokens") { |
| 1044 | has_debug = true; |
| 1045 | assert_eq!( |
| 1046 | commands.len(), |
| 1047 | 13, |
| 1048 | "debug group (group-local metadata exception) expected \ |
| 1049 | exactly 13 commands, got {}", |
| 1050 | commands.len() |
| 1051 | ); |
| 1052 | } |
| 1053 | } |
| 1054 | |
| 1055 | // Config and debug groups must be found and verified by content identity |
| 1056 | assert!( |
| 1057 | has_config, |
| 1058 | "config group not found (expected first command: /config)" |
| 1059 | ); |
| 1060 | assert!( |
| 1061 | has_debug, |
| 1062 | "debug group not found (expected first command: /tokens)" |
| 1063 | ); |
| 1064 | |
| 1065 | // Consistency: group-iterated command count must match registry. |
| 1066 | // FEAT-015 registers one test-only contextual command (`/feat015ctx`) |
| 1067 | // under `#[cfg(test)]` to prove the dual-path seam (D6); the nine |
| 1068 | // production groups remain exactly 96 commands. |
| 1069 | let test_only_count = command_infos() |
| 1070 | .iter() |
| 1071 | .filter(|info| info.name == "feat015ctx") |
| 1072 | .count(); |
| 1073 | assert_eq!( |
| 1074 | total_commands + test_only_count, |
| 1075 | command_infos().len(), |
| 1076 | "group-iterated command count must match registry infos count" |
| 1077 | ); |
| 1078 | } |
| 1079 | |
| 1080 | #[test] |
| 1081 | fn command_groups_are_cached_once() { |
| 1082 | let first_groups = groups::all_command_groups(); |
| 1083 | let second_groups = groups::all_command_groups(); |
| 1084 | assert!( |
| 1085 | std::ptr::eq(first_groups.as_ptr(), second_groups.as_ptr()), |
| 1086 | "command group list should be cached" |
| 1087 | ); |
| 1088 | |
| 1089 | for &group in first_groups { |
| 1090 | let first_commands = group.commands(); |
| 1091 | let second_commands = group.commands(); |
| 1092 | assert!( |
| 1093 | std::ptr::eq(first_commands.as_ptr(), second_commands.as_ptr()), |
| 1094 | "command list should be cached per group" |
| 1095 | ); |
| 1096 | } |
| 1097 | } |
| 1098 | |
| 1099 | #[test] |
| 1100 | fn command_registry_metadata_is_complete_and_palette_safe() { |
| 1101 | for command in command_infos() { |
| 1102 | assert!(!command.name.is_empty(), "command name must not be empty"); |
| 1103 | assert_eq!( |
| 1104 | command.name.trim(), |
| 1105 | command.name, |
| 1106 | "/{} command name must not need trimming", |
| 1107 | command.name |
| 1108 | ); |
| 1109 | assert!( |
| 1110 | is_palette_safe_command_name(command.name), |
| 1111 | "/{} command names must stay lowercase ASCII kebab-case", |
| 1112 | command.name |
| 1113 | ); |
| 1114 | |
| 1115 | let expected_usage_prefix = format!("/{}", command.name); |
| 1116 | assert!( |
| 1117 | command.usage.starts_with(&expected_usage_prefix), |
| 1118 | "/{} usage must start with its canonical slash command, got {:?}", |
| 1119 | command.name, |
| 1120 | command.usage |
| 1121 | ); |
| 1122 | |
| 1123 | let description = command.description_for(Locale::En); |
| 1124 | assert!( |
| 1125 | !description.trim().is_empty(), |
| 1126 | "/{} must have non-empty English help text", |
| 1127 | command.name |
| 1128 | ); |
| 1129 | // #3913: descriptions must not restate the usage field — the |
| 1130 | // palette and /help already append `usage` when arguments exist. |
| 1131 | assert!( |
| 1132 | !description.contains(command.usage), |
| 1133 | "/{} description embeds its usage string {:?}: {description:?}", |
| 1134 | command.name, |
| 1135 | command.usage |
| 1136 | ); |
| 1137 | assert!( |
| 1138 | !description.contains(&format!("/{}", command.name)), |
| 1139 | "/{} description embeds slash-command syntax that usage already covers: {description:?}", |
| 1140 | command.name |
| 1141 | ); |
| 1142 | for banned_prefix in ["Toolbox:", "Reference:"] { |
| 1143 | assert!( |
| 1144 | !description.starts_with(banned_prefix), |
| 1145 | "/{} description should not start with {banned_prefix:?}: {description:?}", |
| 1146 | command.name |
| 1147 | ); |
| 1148 | } |
| 1149 | |
| 1150 | let palette_command = command.palette_command(); |
| 1151 | assert!( |
| 1152 | palette_command.starts_with(&expected_usage_prefix), |
| 1153 | "/{} palette command must use the canonical command, got {:?}", |
| 1154 | command.name, |
| 1155 | palette_command |
| 1156 | ); |
| 1157 | assert_eq!( |
| 1158 | palette_command.ends_with(' '), |
| 1159 | command.requires_argument(), |
| 1160 | "/{} palette command spacing must match argument requirement", |
| 1161 | command.name |
| 1162 | ); |
| 1163 | |
| 1164 | for &alias in command.aliases { |
| 1165 | assert!( |
| 1166 | !alias.trim().is_empty(), |
| 1167 | "/{} alias must not be empty", |
| 1168 | command.name |
| 1169 | ); |
| 1170 | assert_eq!( |
| 1171 | alias.trim(), |
| 1172 | alias, |
| 1173 | "/{} alias /{alias} must not need trimming", |
| 1174 | command.name |
| 1175 | ); |
| 1176 | assert!( |
| 1177 | !alias.starts_with('/'), |
| 1178 | "/{} alias /{alias} must be stored without a slash", |
| 1179 | command.name |
| 1180 | ); |
| 1181 | assert!( |
| 1182 | !alias.chars().any(char::is_whitespace), |
| 1183 | "/{} alias /{alias} must not contain whitespace", |
| 1184 | command.name |
| 1185 | ); |
| 1186 | assert!( |
| 1187 | !alias.chars().any(|ch| ch.is_ascii_uppercase()), |
| 1188 | "/{} alias /{alias} must not contain uppercase ASCII", |
| 1189 | command.name |
| 1190 | ); |
| 1191 | } |
| 1192 | } |
| 1193 | } |
| 1194 | |
| 1195 | #[test] |
| 1196 | fn flagship_orchestration_and_workspace_commands_are_visible_at_the_palette_root() { |
| 1197 | for name in [ |
| 1198 | "auto", |
| 1199 | "dispatch", |
| 1200 | "goal", |
| 1201 | "hooks", |
| 1202 | "tokens", |
| 1203 | "translate", |
| 1204 | "workflow", |
| 1205 | "workspace", |
| 1206 | ] { |
| 1207 | let info = registry() |
| 1208 | .get_info(name) |
| 1209 | .unwrap_or_else(|| panic!("/{name} must be registered")); |
| 1210 | assert!( |
| 1211 | info.show_in_empty_discovery(), |
| 1212 | "/{name} must appear at the palette root (#5442 / #5439)" |
| 1213 | ); |
| 1214 | assert!( |
| 1215 | !traits::ADVANCED_DISCOVERY_COMMANDS.contains(&name), |
| 1216 | "/{name} must not stay on the Advanced discovery list" |
| 1217 | ); |
| 1218 | } |
| 1219 | } |
| 1220 | |
| 1221 | #[test] |
| 1222 | fn command_discovery_tier_lists_use_canonical_registered_names() { |
| 1223 | for (tier_name, names) in [ |
| 1224 | ("advanced", traits::ADVANCED_DISCOVERY_COMMANDS), |
| 1225 | ("compatibility", traits::COMPATIBILITY_DISCOVERY_COMMANDS), |
| 1226 | ] { |
| 1227 | for &name in names { |
| 1228 | let info = registry() |
| 1229 | .get_info(name) |
| 1230 | .unwrap_or_else(|| panic!("{tier_name} discovery entry {name:?} must resolve")); |
| 1231 | assert_eq!( |
| 1232 | info.name, name, |
| 1233 | "{tier_name} discovery entry {name:?} must be canonical, not an alias for /{}", |
| 1234 | info.name |
| 1235 | ); |
| 1236 | } |
| 1237 | } |
| 1238 | } |
| 1239 | |
| 1240 | #[test] |
| 1241 | fn command_info_resolves_canonical_names_and_aliases() { |
| 1242 | for command in command_infos() { |
| 1243 | for lookup in [command.name.to_string(), format!("/{}", command.name)] { |
| 1244 | let resolved = get_command_info(&lookup) |
| 1245 | .unwrap_or_else(|| panic!("{lookup:?} should resolve to /{}", command.name)); |
| 1246 | assert_eq!(resolved.name, command.name); |
| 1247 | } |
| 1248 | |
| 1249 | for &alias in command.aliases { |
| 1250 | for lookup in [alias.to_string(), format!("/{alias}")] { |
| 1251 | let resolved = get_command_info(&lookup).unwrap_or_else(|| { |
| 1252 | panic!("{lookup:?} should resolve to /{}", command.name) |
| 1253 | }); |
| 1254 | assert_eq!(resolved.name, command.name); |
| 1255 | } |
| 1256 | } |
| 1257 | } |
| 1258 | } |
| 1259 | |
| 1260 | #[test] |
| 1261 | fn every_registered_command_has_a_help_topic() { |
| 1262 | let mut app = create_test_app(); |
| 1263 | for command in command_infos() { |
| 1264 | let result = execute(&format!("/help {}", command.name), &mut app); |
| 1265 | assert!( |
| 1266 | !result.is_error, |
| 1267 | "/help {} returned an error: {result:?}", |
| 1268 | command.name |
| 1269 | ); |
| 1270 | let message = result |
| 1271 | .message |
| 1272 | .unwrap_or_else(|| panic!("/help {} should return text", command.name)); |
| 1273 | assert!( |
| 1274 | message.contains(command.name), |
| 1275 | "/help {} should mention the command name, got {message:?}", |
| 1276 | command.name |
| 1277 | ); |
| 1278 | assert!( |
| 1279 | message.contains(command.usage), |
| 1280 | "/help {} should include usage {:?}, got {message:?}", |
| 1281 | command.name, |
| 1282 | command.usage |
| 1283 | ); |
| 1284 | } |
| 1285 | } |
| 1286 | |
| 1287 | #[test] |
| 1288 | fn context_command_opens_inspector_and_keeps_ctx_alias() { |
| 1289 | let context = command_infos() |
| 1290 | .into_iter() |
| 1291 | .find(|cmd| cmd.name == "context") |
| 1292 | .expect("context command should exist"); |
| 1293 | assert_eq!(context.aliases, &["ctx"]); |
| 1294 | assert!(context.description_for(Locale::En).contains("inspector")); |
| 1295 | |
| 1296 | let mut app = create_test_app(); |
| 1297 | let result = execute("/ctx", &mut app); |
| 1298 | assert!(matches!( |
| 1299 | result.action, |
| 1300 | Some(AppAction::OpenContextInspector) |
| 1301 | )); |
| 1302 | |
| 1303 | let report = execute("/context report", &mut app); |
| 1304 | let message = report.message.expect("context report should return text"); |
| 1305 | assert!(message.contains("Context Source Map")); |
| 1306 | } |
| 1307 | |
| 1308 | #[test] |
| 1309 | fn cache_inspect_dispatches_through_cache_command() { |
| 1310 | let mut app = create_test_app(); |
| 1311 | let result = execute("/cache inspect", &mut app); |
| 1312 | let msg = result.message.expect("cache inspect should return text"); |
| 1313 | assert!(msg.contains("Cache Inspect")); |
| 1314 | assert!(msg.contains("Base static prefix hash:")); |
| 1315 | assert!(msg.contains("Full request prefix hash:")); |
| 1316 | assert!(result.action.is_none()); |
| 1317 | } |
| 1318 | |
| 1319 | #[test] |
| 1320 | fn cache_warmup_dispatches_action() { |
| 1321 | let mut app = create_test_app(); |
| 1322 | let result = execute("/cache warmup", &mut app); |
| 1323 | assert!(result.message.is_none()); |
| 1324 | assert!(matches!(result.action, Some(AppAction::CacheWarmup))); |
| 1325 | } |
| 1326 | |
| 1327 | #[test] |
| 1328 | fn execute_config_opens_config_view_action() { |
| 1329 | let mut app = create_test_app(); |
| 1330 | let result = execute("/config", &mut app); |
| 1331 | assert!(result.message.is_none()); |
| 1332 | assert!(matches!(result.action, Some(AppAction::OpenConfigView))); |
| 1333 | } |
| 1334 | |
| 1335 | #[test] |
| 1336 | fn execute_verbose_toggles_live_transcript_detail() { |
| 1337 | let mut app = create_test_app(); |
| 1338 | assert!(!app.verbose_transcript); |
| 1339 | |
| 1340 | let result = execute("/verbose on", &mut app); |
| 1341 | assert!(!result.is_error); |
| 1342 | assert!(app.verbose_transcript); |
| 1343 | assert!(result.message.unwrap().contains("on")); |
| 1344 | |
| 1345 | let result = execute("/verbose off", &mut app); |
| 1346 | assert!(!result.is_error); |
| 1347 | assert!(!app.verbose_transcript); |
| 1348 | assert!(result.message.unwrap().contains("off")); |
| 1349 | } |
| 1350 | |
| 1351 | #[test] |
| 1352 | fn voice_send_and_voice_control_commands_toggle_state() { |
| 1353 | let mut app = create_test_app(); |
| 1354 | assert!(!app.voice_send_enabled); |
| 1355 | assert!(!app.voice_control_enabled); |
| 1356 | |
| 1357 | for invocation in ["/voicesend", "/voice-send", "/yuyinsend", "/语音发送"] { |
| 1358 | let result = execute(invocation, &mut app); |
| 1359 | assert!(!result.is_error, "{invocation} should toggle cleanly"); |
| 1360 | assert!(result.action.is_none()); |
| 1361 | assert!(result.message.is_some()); |
| 1362 | } |
| 1363 | // Four toggles land back at disabled. |
| 1364 | assert!(!app.voice_send_enabled); |
| 1365 | |
| 1366 | let result = execute("/voicecontrol", &mut app); |
| 1367 | assert!(!result.is_error); |
| 1368 | assert!(app.voice_control_enabled); |
| 1369 | let result = execute("/voice-control", &mut app); |
| 1370 | assert!(!result.is_error); |
| 1371 | assert!(!app.voice_control_enabled); |
| 1372 | } |
| 1373 | |
| 1374 | /// `/voice` defers the actual capture to the UI event loop via |
| 1375 | /// `AppAction::VoiceCapture`, so executing it never records audio. |
| 1376 | /// On hosts without a recorder it must fail gracefully instead. |
| 1377 | #[test] |
| 1378 | fn voice_command_toggles_on_and_off_or_fails_gracefully() { |
| 1379 | let mut app = create_test_app(); |
| 1380 | let result = execute("/voice", &mut app); |
| 1381 | if app.voice_enabled { |
| 1382 | assert!(!result.is_error); |
| 1383 | assert!(matches!(result.action, Some(AppAction::VoiceCapture))); |
| 1384 | let off = execute("/voice", &mut app); |
| 1385 | assert!(!off.is_error); |
| 1386 | assert!(off.action.is_none()); |
| 1387 | assert!(!app.voice_enabled); |
| 1388 | } else { |
| 1389 | assert!(result.is_error); |
| 1390 | assert!(result.action.is_none()); |
| 1391 | } |
| 1392 | } |
| 1393 | |
| 1394 | #[test] |
| 1395 | fn execute_rail_sets_placement_and_reports_actual_state() { |
| 1396 | let mut app = create_test_app(); |
| 1397 | |
| 1398 | let result = execute("/workbar off", &mut app); |
| 1399 | assert!(!result.is_error); |
| 1400 | assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Off); |
| 1401 | assert!( |
| 1402 | result |
| 1403 | .message |
| 1404 | .as_deref() |
| 1405 | .unwrap_or_default() |
| 1406 | .contains("Workbar is off") |
| 1407 | ); |
| 1408 | |
| 1409 | let result = execute("/rail right", &mut app); |
| 1410 | assert!(!result.is_error); |
| 1411 | assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Right); |
| 1412 | assert!( |
| 1413 | result |
| 1414 | .message |
| 1415 | .as_deref() |
| 1416 | .unwrap_or_default() |
| 1417 | .contains("right placement") |
| 1418 | ); |
| 1419 | |
| 1420 | // The /rail and /sidebar aliases drive the same workbar. |
| 1421 | let result = execute("/sidebar left", &mut app); |
| 1422 | assert!(!result.is_error); |
| 1423 | assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Left); |
| 1424 | |
| 1425 | let result = execute("/rail top", &mut app); |
| 1426 | assert!(!result.is_error); |
| 1427 | assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Top); |
| 1428 | |
| 1429 | // Bare /workbar reports the actual rendered state; it must never claim |
| 1430 | // visibility for a surface that cannot render. |
| 1431 | app.work_surface.placement = WorkSurfacePlacement::Off; |
| 1432 | let result = execute("/workbar", &mut app); |
| 1433 | assert!(!result.is_error); |
| 1434 | assert!( |
| 1435 | result |
| 1436 | .message |
| 1437 | .as_deref() |
| 1438 | .unwrap_or_default() |
| 1439 | .contains("Workbar is off") |
| 1440 | ); |
| 1441 | } |
| 1442 | |
| 1443 | #[test] |
| 1444 | fn execute_rail_accepts_panel_targets_and_legacy_words() { |
| 1445 | let mut app = create_test_app(); |
| 1446 | |
| 1447 | let result = execute("/rail agents", &mut app); |
| 1448 | assert!(!result.is_error); |
| 1449 | assert_eq!(app.work_surface.panel, RailPanel::Agents); |
| 1450 | |
| 1451 | let result = execute("/sidebar context", &mut app); |
| 1452 | assert!(!result.is_error); |
| 1453 | assert_eq!(app.work_surface.panel, RailPanel::Context); |
| 1454 | |
| 1455 | let result = execute("/rail activity", &mut app); |
| 1456 | assert!(!result.is_error); |
| 1457 | assert_eq!( |
| 1458 | app.work_surface.panel, |
| 1459 | RailPanel::Tasks, |
| 1460 | "activity maps onto the Tasks panel" |
| 1461 | ); |
| 1462 | |
| 1463 | let result = execute("/rail pinned", &mut app); |
| 1464 | assert!(!result.is_error); |
| 1465 | assert_eq!( |
| 1466 | app.work_surface.panel, |
| 1467 | RailPanel::Tasks, |
| 1468 | "pinned folded into the tasks view" |
| 1469 | ); |
| 1470 | let result = execute("/rail files", &mut app); |
| 1471 | assert!(!result.is_error); |
| 1472 | assert_eq!(app.work_surface.panel, RailPanel::Files); |
| 1473 | |
| 1474 | let result = execute("/sidebar on", &mut app); |
| 1475 | assert!(!result.is_error); |
| 1476 | assert_eq!( |
| 1477 | app.work_surface.placement, |
| 1478 | WorkSurfacePlacement::Bottom, |
| 1479 | "on restores the default bottom workbar (round 3)" |
| 1480 | ); |
| 1481 | |
| 1482 | let result = execute("/sidebar none", &mut app); |
| 1483 | assert!(!result.is_error); |
| 1484 | assert_eq!(app.work_surface.placement, WorkSurfacePlacement::Off); |
| 1485 | } |
| 1486 | |
| 1487 | #[test] |
| 1488 | fn execute_rail_rejects_invalid_args() { |
| 1489 | let mut app = create_test_app(); |
| 1490 | let result = execute("/rail maybe", &mut app); |
| 1491 | assert!(result.is_error); |
| 1492 | assert!( |
| 1493 | result |
| 1494 | .message |
| 1495 | .as_deref() |
| 1496 | .unwrap_or_default() |
| 1497 | .contains("Usage: /workbar") |
| 1498 | ); |
| 1499 | } |
| 1500 | |
| 1501 | #[test] |
| 1502 | fn execute_links_and_aliases_return_links_message() { |
| 1503 | let mut app = create_test_app(); |
| 1504 | for cmd in ["/links", "/dashboard", "/api", "/lianjie"] { |
| 1505 | let result = execute(cmd, &mut app); |
| 1506 | let msg = result.message.expect("links commands should return text"); |
| 1507 | assert!(msg.contains("https://codewhale.net/en/docs")); |
| 1508 | assert!(msg.contains("https://codewhale.net/en/community")); |
| 1509 | assert!(msg.contains("https://github.com/Hmbown/CodeWhale")); |
| 1510 | assert!(msg.contains("https://app.codewhale.net")); |
| 1511 | assert!(msg.contains("separate sign-in")); |
| 1512 | assert!(msg.contains("not connected to the current local session")); |
| 1513 | assert!(msg.contains("https://platform.deepseek.com")); |
| 1514 | assert!(result.action.is_none()); |
| 1515 | } |
| 1516 | } |
| 1517 | |
| 1518 | #[test] |
| 1519 | fn execute_workspace_alias_switches_workspace() { |
| 1520 | let dir = tempdir().expect("temp dir"); |
| 1521 | let mut app = create_test_app(); |
| 1522 | let result = execute(&format!("/cwd {}", dir.path().display()), &mut app); |
| 1523 | assert!(matches!( |
| 1524 | result.action, |
| 1525 | Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() |
| 1526 | )); |
| 1527 | } |
| 1528 | |
| 1529 | #[test] |
| 1530 | fn removed_set_and_deepseek_commands_show_migration_hints() { |
| 1531 | let mut app = create_test_app(); |
| 1532 | let set_result = execute("/set model deepseek-v4-pro", &mut app); |
| 1533 | let set_msg = set_result |
| 1534 | .message |
| 1535 | .expect("legacy command should return an error message"); |
| 1536 | assert!(set_msg.contains("The /set command was retired")); |
| 1537 | assert!(set_msg.contains("/config")); |
| 1538 | assert!(set_msg.contains("/settings")); |
| 1539 | assert!(set_result.action.is_none()); |
| 1540 | |
| 1541 | let deepseek_result = execute("/deepseek", &mut app); |
| 1542 | let deepseek_msg = deepseek_result |
| 1543 | .message |
| 1544 | .expect("legacy command should return an error message"); |
| 1545 | assert!(deepseek_msg.contains("The /deepseek command was renamed")); |
| 1546 | assert!(deepseek_msg.contains("/links")); |
| 1547 | assert!(deepseek_msg.contains("/dashboard")); |
| 1548 | assert!(deepseek_msg.contains("/api")); |
| 1549 | assert!(deepseek_result.action.is_none()); |
| 1550 | } |
| 1551 | |
| 1552 | struct ConfigPathGuard { |
| 1553 | previous: Option<OsString>, |
| 1554 | _lock: crate::test_support::TestEnvLock, |
| 1555 | } |
| 1556 | |
| 1557 | impl ConfigPathGuard { |
| 1558 | fn new(config_path: &Path) -> Self { |
| 1559 | let lock = crate::test_support::lock_test_env(); |
| 1560 | let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH"); |
| 1561 | // Safety: test-only environment mutation guarded by a global mutex. |
| 1562 | unsafe { |
| 1563 | std::env::set_var("DEEPSEEK_CONFIG_PATH", config_path); |
| 1564 | } |
| 1565 | Self { |
| 1566 | previous, |
| 1567 | _lock: lock, |
| 1568 | } |
| 1569 | } |
| 1570 | } |
| 1571 | |
| 1572 | impl Drop for ConfigPathGuard { |
| 1573 | fn drop(&mut self) { |
| 1574 | // Safety: test-only environment mutation guarded by a global mutex. |
| 1575 | unsafe { |
| 1576 | if let Some(previous) = self.previous.take() { |
| 1577 | std::env::set_var("DEEPSEEK_CONFIG_PATH", previous); |
| 1578 | } else { |
| 1579 | std::env::remove_var("DEEPSEEK_CONFIG_PATH"); |
| 1580 | } |
| 1581 | } |
| 1582 | } |
| 1583 | } |
| 1584 | |
| 1585 | /// Build an App scoped to an isolated tempdir so dispatch-side-effects |
| 1586 | /// (e.g. `/init` writing AGENTS.md, explicit `/export <path>` writes, or |
| 1587 | /// `/logout` clearing credentials) don't pollute the repo working tree or |
| 1588 | /// the developer's real config when the smoke tests run. |
| 1589 | fn create_isolated_test_app() -> (App, tempfile::TempDir, ConfigPathGuard) { |
| 1590 | let tmpdir = tempfile::TempDir::new().expect("tempdir for smoke test"); |
| 1591 | let workspace = tmpdir.path().to_path_buf(); |
| 1592 | let config_path = workspace.join(".deepseek").join("config.toml"); |
| 1593 | std::fs::create_dir_all(config_path.parent().expect("config parent")).expect("config dir"); |
| 1594 | let guard = ConfigPathGuard::new(&config_path); |
| 1595 | let options = TuiOptions { |
| 1596 | config_path: Some(config_path), |
| 1597 | skills_dir: workspace.join("skills"), |
| 1598 | memory_path: workspace.join("memory.md"), |
| 1599 | notes_path: workspace.join("notes.txt"), |
| 1600 | mcp_config_path: workspace.join("mcp.json"), |
| 1601 | ..crate::test_support::test_tui_options(workspace.clone()) |
| 1602 | }; |
| 1603 | let app = App::new(options, &Config::default()); |
| 1604 | assert!( |
| 1605 | app.dispatch_completion_tx.is_none(), |
| 1606 | "dispatch smoke fixtures must not permit native window mutations" |
| 1607 | ); |
| 1608 | (app, tmpdir, guard) |
| 1609 | } |
| 1610 | |
| 1611 | /// Smoke test: every entry in `command_infos()` must dispatch to a real handler. |
| 1612 | /// A dispatch miss surfaces as the fall-through `Unknown command:` error |
| 1613 | /// message in `execute`. This catches the case where a new command is |
| 1614 | /// added to `command_infos()` (so it shows up in `/help` and the palette) but |
| 1615 | /// the matching arm in `execute` is forgotten — the user would type the |
| 1616 | /// command, see it autocomplete, and then get an unhelpful "did you |
| 1617 | /// mean" suggestion. Also catches panics in handlers because the test |
| 1618 | /// runner unwinds the panic and reports the offending command. |
| 1619 | /// `/save` still defaults its output path, while `/export` accepts a legacy |
| 1620 | /// direct file path. Pass explicit tempdir paths so this smoke test covers |
| 1621 | /// both file handlers without touching the developer's clipboard. |
| 1622 | fn invocation_for(command_name: &str, alias_or_name: &str, tmpdir: &std::path::Path) -> String { |
| 1623 | match command_name { |
| 1624 | "save" => format!("/{alias_or_name} {}", tmpdir.join("session.json").display()), |
| 1625 | "export" => format!("/{alias_or_name} {}", tmpdir.join("chat.md").display()), |
| 1626 | _ => format!("/{alias_or_name}"), |
| 1627 | } |
| 1628 | } |
| 1629 | |
| 1630 | /// `/restore` is covered by its own dedicated tests in |
| 1631 | /// `commands/restore.rs` that serialize on the global env mutex via |
| 1632 | /// `scoped_home` (snapshot repo init shells out to git, which races |
| 1633 | /// against parallel-running tests). Skip it here so this smoke test |
| 1634 | /// stays parallel-safe. |
| 1635 | /// |
| 1636 | /// `/pin` is covered on every platform. The headless fixture has no |
| 1637 | /// completion mailbox, so Windows rejects it before resolving or changing |
| 1638 | /// a host window; the former synchronous message-pump wait cannot occur. |
| 1639 | fn skip_in_dispatch_smoke(name: &str) -> bool { |
| 1640 | name == "restore" |
| 1641 | } |
| 1642 | |
| 1643 | /// Upper bound on a single command dispatch in the smoke tests. |
| 1644 | /// |
| 1645 | /// Generous next to the millisecond each handler actually takes, and far |
| 1646 | /// below nextest's 600 s test timeout, so a handler that blocks fails the |
| 1647 | /// test *by name* instead of burning a ten-minute CI slot with no |
| 1648 | /// attribution (#5919). |
| 1649 | const DISPATCH_WATCHDOG: std::time::Duration = std::time::Duration::from_secs(30); |
| 1650 | |
| 1651 | /// Dispatch one command under a per-command watchdog and return the |
| 1652 | /// handler's message. |
| 1653 | /// |
| 1654 | /// The app is built and the command executed on a dedicated thread; the |
| 1655 | /// test thread waits on the result with a timeout. A handler that never |
| 1656 | /// returns leaves its thread parked, but the test itself fails |
| 1657 | /// immediately, naming the invocation. A handler that panics still |
| 1658 | /// surfaces as that panic — the smoke tests are the repo's only |
| 1659 | /// panic-in-a-handler net, so the payload is resumed rather than |
| 1660 | /// swallowed. |
| 1661 | fn dispatch_under_watchdog(command_name: &str, alias_or_name: &str) -> Option<String> { |
| 1662 | let label = format!("/{alias_or_name}"); |
| 1663 | let (tx, rx) = std::sync::mpsc::channel(); |
| 1664 | let name = command_name.to_string(); |
| 1665 | let alias = alias_or_name.to_string(); |
| 1666 | let handle = std::thread::Builder::new() |
| 1667 | .name(format!("dispatch-smoke-{alias_or_name}")) |
| 1668 | // Command handlers are deeply recursive in debug builds; match the |
| 1669 | // 16 MiB the CI runner sets via RUST_MIN_STACK for the main thread. |
| 1670 | .stack_size(16 * 1024 * 1024) |
| 1671 | .spawn(move || { |
| 1672 | let (mut app, tmpdir, _guard) = create_isolated_test_app(); |
| 1673 | let invocation = invocation_for(&name, &alias, tmpdir.path()); |
| 1674 | let result = execute(&invocation, &mut app); |
| 1675 | let _ = tx.send(result.message); |
| 1676 | }) |
| 1677 | .expect("spawn dispatch smoke thread"); |
| 1678 | |
| 1679 | let started = std::time::Instant::now(); |
| 1680 | match rx.recv_timeout(DISPATCH_WATCHDOG) { |
| 1681 | Ok(message) => { |
| 1682 | let _ = handle.join(); |
| 1683 | // Quiet on the common path; a handler heading for the |
| 1684 | // watchdog still leaves a named breadcrumb in the log. |
| 1685 | let elapsed = started.elapsed(); |
| 1686 | if elapsed > std::time::Duration::from_secs(1) { |
| 1687 | eprintln!("dispatch smoke: {label} took {elapsed:?}"); |
| 1688 | } |
| 1689 | message |
| 1690 | } |
| 1691 | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!( |
| 1692 | "{label} did not return within {DISPATCH_WATCHDOG:?}: its handler blocks. \ |
| 1693 | Fix the handler or add it to skip_in_dispatch_smoke with a reason." |
| 1694 | ), |
| 1695 | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => match handle.join() { |
| 1696 | Ok(()) => panic!("{label} dispatch thread ended without producing a result"), |
| 1697 | Err(payload) => std::panic::resume_unwind(payload), |
| 1698 | }, |
| 1699 | } |
| 1700 | } |
| 1701 | |
| 1702 | #[test] |
| 1703 | fn slash_parser_preserves_arguments_after_the_command_name() { |
| 1704 | let mut app = create_test_app(); |
| 1705 | let result = execute("/agent 2 review this carefully", &mut app); |
| 1706 | assert!(!result.is_error); |
| 1707 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 1708 | panic!("expected /agent to send a model instruction"); |
| 1709 | }; |
| 1710 | assert!(message.contains(r#"prompt: "review this carefully""#)); |
| 1711 | assert!(message.contains("max_depth: 2")); |
| 1712 | |
| 1713 | let mut app = create_test_app(); |
| 1714 | let result = execute(" /relay ship command harness ", &mut app); |
| 1715 | assert!(!result.is_error); |
| 1716 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 1717 | panic!("expected /relay to send a model instruction"); |
| 1718 | }; |
| 1719 | assert!(message.contains("Requested relay focus: ship command harness")); |
| 1720 | |
| 1721 | let mut app = create_test_app(); |
| 1722 | let result = execute("/rlm 3 inspect this corpus", &mut app); |
| 1723 | assert!(!result.is_error); |
| 1724 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 1725 | panic!("expected /rlm to send a model instruction"); |
| 1726 | }; |
| 1727 | assert!(message.contains(r#"this text: "inspect this corpus""#)); |
| 1728 | assert!(message.contains("session-persistent working context")); |
| 1729 | } |
| 1730 | |
| 1731 | #[test] |
| 1732 | fn representative_command_groups_keep_dispatch_surfaces() { |
| 1733 | let mut app = create_test_app(); |
| 1734 | let help = execute("/help clear", &mut app) |
| 1735 | .message |
| 1736 | .expect("/help clear should return text"); |
| 1737 | assert!(help.contains("clear")); |
| 1738 | assert!(help.contains("/clear")); |
| 1739 | |
| 1740 | let mut app = create_test_app(); |
| 1741 | let result = execute("/config", &mut app); |
| 1742 | assert!(matches!(result.action, Some(AppAction::OpenConfigView))); |
| 1743 | |
| 1744 | let mut app = create_test_app(); |
| 1745 | let result = execute("/relay command boundary", &mut app); |
| 1746 | assert!(!result.is_error); |
| 1747 | assert!(matches!( |
| 1748 | result.action, |
| 1749 | Some(AppAction::SendMessage(message)) |
| 1750 | if message.contains("Requested relay focus: command boundary") |
| 1751 | )); |
| 1752 | |
| 1753 | let mut app = create_test_app(); |
| 1754 | let note_help = execute("/note help", &mut app) |
| 1755 | .message |
| 1756 | .expect("/note help should return text"); |
| 1757 | assert!(note_help.contains("Usage: /note")); |
| 1758 | |
| 1759 | let mut app = create_test_app(); |
| 1760 | let result = execute("/goal ship layer 2 | budget: 100", &mut app); |
| 1761 | assert!(!result.is_error); |
| 1762 | assert!(matches!( |
| 1763 | result.action, |
| 1764 | Some(AppAction::SetGoalObjective { |
| 1765 | ref objective, |
| 1766 | token_budget: Some(100) |
| 1767 | }) if objective == "ship layer 2" |
| 1768 | )); |
| 1769 | // The hunt-era alias is gone: `/hunt` must not resolve anymore. |
| 1770 | assert!(execute("/hunt ship layer 2", &mut app).is_error); |
| 1771 | |
| 1772 | let (mut app, _tmpdir, _guard) = create_isolated_test_app(); |
| 1773 | let result = execute("/skills", &mut app); |
| 1774 | assert!(matches!( |
| 1775 | result.action, |
| 1776 | Some(AppAction::OpenExtensions { |
| 1777 | tab: crate::tui::views::extensions::ExtensionsTab::Skills |
| 1778 | }) |
| 1779 | )); |
| 1780 | |
| 1781 | let mut app = create_test_app(); |
| 1782 | let result = execute("/task list", &mut app); |
| 1783 | assert!(matches!(result.action, Some(AppAction::TaskList))); |
| 1784 | |
| 1785 | let mut app = create_test_app(); |
| 1786 | let tokens = execute("/tokens", &mut app) |
| 1787 | .message |
| 1788 | .expect("/tokens should return text"); |
| 1789 | assert!(tokens.contains("deepseek-v4-pro")); |
| 1790 | } |
| 1791 | |
| 1792 | /// Smoke test: every entry in `command_infos()` must dispatch to a real handler. |
| 1793 | /// A dispatch miss surfaces as the fall-through `Unknown command:` error |
| 1794 | /// message in `execute`. This catches the case where a new command is |
| 1795 | /// added to `command_infos()` (so it shows up in `/help` and the palette) but |
| 1796 | /// the matching arm in `execute` is forgotten — the user would type the |
| 1797 | /// command, see it autocomplete, and then get an unhelpful "did you |
| 1798 | /// mean" suggestion. Also catches panics in handlers because the test |
| 1799 | /// runner unwinds the panic and reports the offending command. |
| 1800 | #[test] |
| 1801 | fn every_registered_command_dispatches_to_a_handler() { |
| 1802 | for command in command_infos() { |
| 1803 | if skip_in_dispatch_smoke(command.name) { |
| 1804 | continue; |
| 1805 | } |
| 1806 | if let Some(msg) = dispatch_under_watchdog(command.name, command.name) { |
| 1807 | assert!( |
| 1808 | !msg.contains("Unknown command"), |
| 1809 | "/{} fell through to the unknown-command branch: {msg}", |
| 1810 | command.name, |
| 1811 | ); |
| 1812 | } |
| 1813 | } |
| 1814 | } |
| 1815 | |
| 1816 | /// Same check, but for declared aliases — `/q` should not fall through |
| 1817 | /// just because the registry lists it as an alias of `/exit`. |
| 1818 | #[test] |
| 1819 | fn every_command_alias_dispatches_to_a_handler() { |
| 1820 | for command in command_infos() { |
| 1821 | if skip_in_dispatch_smoke(command.name) { |
| 1822 | continue; |
| 1823 | } |
| 1824 | for alias in command.aliases { |
| 1825 | if let Some(msg) = dispatch_under_watchdog(command.name, alias) { |
| 1826 | assert!( |
| 1827 | !msg.contains("Unknown command"), |
| 1828 | "/{alias} (alias of /{}) fell through to unknown: {msg}", |
| 1829 | command.name, |
| 1830 | ); |
| 1831 | } |
| 1832 | } |
| 1833 | } |
| 1834 | } |
| 1835 | |
| 1836 | #[test] |
| 1837 | fn balance_command_has_own_help_text() { |
| 1838 | let info = get_command_info("balance").expect("balance command should be registered"); |
| 1839 | assert_eq!(info.description_id, MessageId::CmdBalanceDescription); |
| 1840 | assert!( |
| 1841 | info.description_for(Locale::En) |
| 1842 | .contains("provider account balance") |
| 1843 | ); |
| 1844 | } |
| 1845 | |
| 1846 | #[test] |
| 1847 | fn balance_command_dispatches_live_fetch_for_prepaid_providers() { |
| 1848 | let mut app = create_test_app(); |
| 1849 | for provider in [ |
| 1850 | ApiProvider::Deepseek, |
| 1851 | ApiProvider::Openrouter, |
| 1852 | ApiProvider::Siliconflow, |
| 1853 | ] { |
| 1854 | app.api_provider = provider; |
| 1855 | let result = execute("/balance", &mut app); |
| 1856 | assert!(!result.is_error, "{provider:?}"); |
| 1857 | assert!( |
| 1858 | matches!(result.action, Some(AppAction::FetchBalance)), |
| 1859 | "{provider:?} should dispatch a live remaining-credit fetch" |
| 1860 | ); |
| 1861 | } |
| 1862 | } |
| 1863 | |
| 1864 | #[test] |
| 1865 | fn balance_command_reports_unsupported_provider_clearly() { |
| 1866 | let mut app = create_test_app(); |
| 1867 | app.api_provider = ApiProvider::Ollama; |
| 1868 | |
| 1869 | let result = execute("/balance", &mut app); |
| 1870 | let msg = result |
| 1871 | .message |
| 1872 | .expect("unsupported providers should return a clear message"); |
| 1873 | |
| 1874 | assert!(!result.is_error); |
| 1875 | assert!(msg.contains("Ollama")); |
| 1876 | assert!(msg.contains("not supported")); |
| 1877 | assert!(msg.contains("dashboard")); |
| 1878 | } |
| 1879 | |
| 1880 | #[test] |
| 1881 | fn unknown_command_suggests_nearest_match() { |
| 1882 | let mut app = create_test_app(); |
| 1883 | let result = execute("/modle", &mut app); |
| 1884 | let msg = result |
| 1885 | .message |
| 1886 | .expect("unknown command should return an error message"); |
| 1887 | assert!(msg.contains("Unknown command: /modle")); |
| 1888 | assert!(msg.contains("Did you mean:")); |
| 1889 | assert!(msg.contains("/model")); |
| 1890 | } |
| 1891 | |
| 1892 | #[test] |
| 1893 | fn unknown_command_without_close_match_keeps_help_guidance() { |
| 1894 | let mut app = create_test_app(); |
| 1895 | let result = execute("/zzzzzz", &mut app); |
| 1896 | let msg = result |
| 1897 | .message |
| 1898 | .expect("unknown command should return an error message"); |
| 1899 | assert!(msg.contains("Unknown command: /zzzzzz")); |
| 1900 | assert!(msg.contains("Type /help for available commands.")); |
| 1901 | } |
| 1902 | |
| 1903 | #[test] |
| 1904 | fn dollar_skill_prefix_with_no_name_shows_usage() { |
| 1905 | let mut app = create_test_app(); |
| 1906 | let result = execute("$", &mut app); |
| 1907 | assert!(result.is_error); |
| 1908 | let msg = result.message.expect("should return error message"); |
| 1909 | assert!(msg.contains("Type a skill name after $")); |
| 1910 | } |
| 1911 | |
| 1912 | #[test] |
| 1913 | fn dollar_skill_prefix_unknown_skill_reports_unknown_skill() { |
| 1914 | let mut app = create_test_app(); |
| 1915 | let result = execute("$definitely-not-a-real-skill-12345", &mut app); |
| 1916 | assert!(result.is_error); |
| 1917 | let msg = result.message.expect("should return error message"); |
| 1918 | assert!(msg.contains("Unknown skill: $definitely-not-a-real-skill-12345")); |
| 1919 | assert!(msg.contains("/skills")); |
| 1920 | } |
| 1921 | |
| 1922 | #[test] |
| 1923 | fn dollar_skill_prefix_does_not_break_existing_slash_dispatch() { |
| 1924 | let mut app = create_test_app(); |
| 1925 | let result = execute("/help", &mut app); |
| 1926 | assert!(!result.is_error); |
| 1927 | } |
| 1928 | |
| 1929 | fn write_test_skill(root: &Path, name: &str) { |
| 1930 | let skill_dir = root.join("skills").join(name); |
| 1931 | std::fs::create_dir_all(&skill_dir).expect("skill directory"); |
| 1932 | std::fs::write( |
| 1933 | skill_dir.join("SKILL.md"), |
| 1934 | format!( |
| 1935 | "---\nname: {name}\ndescription: Test {name} skill\n---\nFollow the test instructions." |
| 1936 | ), |
| 1937 | ) |
| 1938 | .expect("skill fixture"); |
| 1939 | } |
| 1940 | |
| 1941 | #[test] |
| 1942 | fn task_bearing_skill_invocations_send_the_task_on_the_activated_turn() { |
| 1943 | for invocation in ["$foo do X", "/foo do X", "/skill foo do X"] { |
| 1944 | let (mut app, tmpdir, _guard) = create_isolated_test_app(); |
| 1945 | write_test_skill(tmpdir.path(), "foo"); |
| 1946 | |
| 1947 | let result = execute(invocation, &mut app); |
| 1948 | |
| 1949 | assert!(!result.is_error, "{invocation}: {result:?}"); |
| 1950 | assert!( |
| 1951 | result |
| 1952 | .message |
| 1953 | .as_deref() |
| 1954 | .is_some_and(|message| message.contains("Skill 'foo' activated")), |
| 1955 | "{invocation}: {result:?}" |
| 1956 | ); |
| 1957 | assert!( |
| 1958 | matches!(result.action, Some(AppAction::SendMessage(ref task)) if task == "do X"), |
| 1959 | "{invocation}: {result:?}" |
| 1960 | ); |
| 1961 | assert!( |
| 1962 | app.active_skill |
| 1963 | .as_deref() |
| 1964 | .is_some_and(|instruction| instruction.contains("# Skill: foo")), |
| 1965 | "{invocation} did not arm foo for the dispatched task" |
| 1966 | ); |
| 1967 | } |
| 1968 | } |
| 1969 | |
| 1970 | #[test] |
| 1971 | fn bare_dollar_skill_still_arms_the_next_message() { |
| 1972 | let (mut app, tmpdir, _guard) = create_isolated_test_app(); |
| 1973 | write_test_skill(tmpdir.path(), "foo"); |
| 1974 | |
| 1975 | let result = execute("$foo", &mut app); |
| 1976 | |
| 1977 | assert!(!result.is_error, "{result:?}"); |
| 1978 | assert!(result.action.is_none()); |
| 1979 | assert!( |
| 1980 | app.active_skill |
| 1981 | .as_deref() |
| 1982 | .is_some_and(|instruction| instruction.contains("# Skill: foo")) |
| 1983 | ); |
| 1984 | } |
| 1985 | |
| 1986 | #[test] |
| 1987 | fn shorthand_can_invoke_a_skill_named_install_without_stealing_management_commands() { |
| 1988 | for invocation in ["$install do X", "/install do X"] { |
| 1989 | let (mut app, tmpdir, _guard) = create_isolated_test_app(); |
| 1990 | write_test_skill(tmpdir.path(), "install"); |
| 1991 | |
| 1992 | let result = execute(invocation, &mut app); |
| 1993 | |
| 1994 | assert!(!result.is_error, "{invocation}: {result:?}"); |
| 1995 | assert!( |
| 1996 | matches!(result.action, Some(AppAction::SendMessage(ref task)) if task == "do X"), |
| 1997 | "{invocation}: {result:?}" |
| 1998 | ); |
| 1999 | assert!( |
| 2000 | app.active_skill |
| 2001 | .as_deref() |
| 2002 | .is_some_and(|instruction| instruction.contains("# Skill: install")), |
| 2003 | "{invocation} did not activate the install skill" |
| 2004 | ); |
| 2005 | } |
| 2006 | |
| 2007 | let (mut app, tmpdir, _guard) = create_isolated_test_app(); |
| 2008 | write_test_skill(tmpdir.path(), "install"); |
| 2009 | let result = execute("/skill install", &mut app); |
| 2010 | assert!(result.is_error, "management subcommand should show usage"); |
| 2011 | assert!( |
| 2012 | result |
| 2013 | .message |
| 2014 | .as_deref() |
| 2015 | .is_some_and(|message| message.contains("/skill install")) |
| 2016 | ); |
| 2017 | assert!(result.action.is_none()); |
| 2018 | assert!(app.active_skill.is_none()); |
| 2019 | } |
| 2020 | |
| 2021 | // --------------------------------------------------------------------- |
| 2022 | // FEAT-015: test-only contextual dispatch through the public dispatcher |
| 2023 | // (D6). The fixture is registered into the global registry only in test |
| 2024 | // builds and executes through the public `execute()`. |
| 2025 | // --------------------------------------------------------------------- |
| 2026 | |
| 2027 | #[test] |
| 2028 | fn feat015_contextual_command_executes_through_public_dispatcher() { |
| 2029 | let mut app = create_test_app(); |
| 2030 | let result = execute("/feat015ctx hello", &mut app); |
| 2031 | assert!(!result.is_error, "{result:?}"); |
| 2032 | let message = result.message.expect("message"); |
| 2033 | assert!(message.contains("workspace="), "{message}"); |
| 2034 | assert!(message.contains("mode="), "{message}"); |
| 2035 | assert!(message.contains("currency="), "{message}"); |
| 2036 | assert!(message.contains("arg=hello"), "{message}"); |
| 2037 | assert!(result.action.is_none()); |
| 2038 | } |
| 2039 | |
| 2040 | #[test] |
| 2041 | fn feat015_contextual_command_fails_safely_without_declared_facets() { |
| 2042 | let result = feat015_contextual( |
| 2043 | codewhale_command_contract::handler::CommandContexts::empty(), |
| 2044 | None, |
| 2045 | ); |
| 2046 | assert!(result.is_error, "{result:?}"); |
| 2047 | assert_eq!( |
| 2048 | result.message.as_deref(), |
| 2049 | Some("Error: Command capability unavailable: workspace") |
| 2050 | ); |
| 2051 | assert!(result.action.is_none()); |
| 2052 | } |
| 2053 | |
| 2054 | #[test] |
| 2055 | fn feat015_contextual_command_is_registered_only_in_test_builds() { |
| 2056 | // The fixture entry is present in the test-build registry with a |
| 2057 | // capability-scoped handler; production builds never see it. |
| 2058 | assert!(registry().has_contextual_handler("feat015ctx")); |
| 2059 | let info = registry().get_info("feat015ctx").expect("info"); |
| 2060 | assert_eq!(info.name, "feat015ctx"); |
| 2061 | assert_eq!( |
| 2062 | info.description_id, |
| 2063 | codewhale_localization::MessageId::CmdWorkspaceDescription, |
| 2064 | "portable description_key must bridge to the TUI localization id" |
| 2065 | ); |
| 2066 | } |
| 2067 | |
| 2068 | #[test] |
| 2069 | fn feat015_all_production_entries_remain_legacy() { |
| 2070 | // FEAT-015 shipped no production contextual command, so the assertion |
| 2071 | // below used to exclude nothing. FEAT-018 migrates the utility group; |
| 2072 | // FEAT-019 migrates the memory group; FEAT-021 migrates the project group. |
| 2073 | const MIGRATED_GROUPS: &[&str] = &[ |
| 2074 | // FEAT-018 utility group. |
| 2075 | "attach", |
| 2076 | "automation", |
| 2077 | "dispatch", |
| 2078 | "jobs", |
| 2079 | "mcp", |
| 2080 | "network", |
| 2081 | "task", |
| 2082 | "update", |
| 2083 | // FEAT-021 project group. |
| 2084 | "init", |
| 2085 | "lsp", |
| 2086 | "share", |
| 2087 | "goal", |
| 2088 | // FEAT-019 memory group. |
| 2089 | "note", |
| 2090 | "memory", |
| 2091 | // FEAT-020 plugins group. |
| 2092 | "plugin", |
| 2093 | // FEAT-022 skills group. |
| 2094 | "skills", |
| 2095 | "skill", |
| 2096 | "review", |
| 2097 | "restore", |
| 2098 | // FEAT-023 session lifecycle slice. |
| 2099 | "branch", |
| 2100 | "compact", |
| 2101 | "fork", |
| 2102 | "load", |
| 2103 | "new", |
| 2104 | "purge", |
| 2105 | "save", |
| 2106 | "sessions", |
| 2107 | "tree", |
| 2108 | // FEAT-024 session control slice. |
| 2109 | "relay", |
| 2110 | "rename", |
| 2111 | "resume", |
| 2112 | "rc", |
| 2113 | "remote-env", |
| 2114 | "title", |
| 2115 | // FEAT-025 session export slice. |
| 2116 | "export", |
| 2117 | ]; |
| 2118 | for info in command_infos() { |
| 2119 | if info.name == "feat015ctx" || MIGRATED_GROUPS.contains(&info.name) { |
| 2120 | continue; |
| 2121 | } |
| 2122 | assert!( |
| 2123 | !registry().has_contextual_handler(info.name), |
| 2124 | "/{} must remain on the legacy dispatch path", |
| 2125 | info.name |
| 2126 | ); |
| 2127 | } |
| 2128 | } |
| 2129 | |
| 2130 | // --------------------------------------------------------------------- |
| 2131 | // FEAT-018: public pure/contextual dispatch and seven-entry inventory |
| 2132 | // (Task 6.2). These tests enter through the public registry/dispatch seam |
| 2133 | // and prove both handler variants plus all seven utility metadata records. |
| 2134 | // --------------------------------------------------------------------- |
| 2135 | |
| 2136 | #[test] |
| 2137 | fn feat018_all_seven_utility_entries_are_registered_with_portable_handlers() { |
| 2138 | for name in [ |
| 2139 | "attach", |
| 2140 | "automation", |
| 2141 | "jobs", |
| 2142 | "mcp", |
| 2143 | "network", |
| 2144 | "task", |
| 2145 | "update", |
| 2146 | ] { |
| 2147 | let info = registry() |
| 2148 | .get_info(name) |
| 2149 | .unwrap_or_else(|| panic!("/{name} must be registered")); |
| 2150 | assert_eq!(info.name, name, "canonical name"); |
| 2151 | assert!( |
| 2152 | registry().has_contextual_handler(name), |
| 2153 | "/{name} must carry a portable handler" |
| 2154 | ); |
| 2155 | } |
| 2156 | } |
| 2157 | |
| 2158 | #[test] |
| 2159 | fn feat018_pure_utility_command_dispatches_through_public_seam() { |
| 2160 | let mut app = create_test_app(); |
| 2161 | // /jobs is a Pure handler: it must execute without building an |
| 2162 | // envelope and return the same action as the parser. |
| 2163 | let result = execute("/jobs list", &mut app); |
| 2164 | assert!(!result.is_error, "{result:?}"); |
| 2165 | assert!( |
| 2166 | matches!( |
| 2167 | result.action, |
| 2168 | Some(crate::tui::app::AppAction::ShellJob( |
| 2169 | crate::tui::app::ShellJobAction::List |
| 2170 | )) |
| 2171 | ), |
| 2172 | "{result:?}" |
| 2173 | ); |
| 2174 | |
| 2175 | // /update is Pure too; a bare check should reach the plan resolver and |
| 2176 | // return a message (or a safe error in a test environment), never a panic. |
| 2177 | let result = execute("/update", &mut app); |
| 2178 | assert!(result.message.is_some() || result.is_error, "{result:?}"); |
| 2179 | } |
| 2180 | |
| 2181 | #[test] |
| 2182 | fn feat018_contextual_utility_commands_dispatch_through_public_seam() { |
| 2183 | let mut app = create_test_app(); |
| 2184 | |
| 2185 | // /automation (contextual, presentation facet): list action. |
| 2186 | let automation = execute("/automation list", &mut app); |
| 2187 | assert!( |
| 2188 | matches!( |
| 2189 | automation.action, |
| 2190 | Some(crate::tui::app::AppAction::Automation( |
| 2191 | crate::tui::app::AutomationAction::List |
| 2192 | )) |
| 2193 | ), |
| 2194 | "{automation:?}" |
| 2195 | ); |
| 2196 | |
| 2197 | // /task (contextual, workspace facet): digest without a runtime must |
| 2198 | // produce the canonical no-active text. |
| 2199 | let task = execute("/task digest", &mut app); |
| 2200 | assert_eq!( |
| 2201 | task.message.as_deref(), |
| 2202 | Some("No active operations or to-do items."), |
| 2203 | "{task:?}" |
| 2204 | ); |
| 2205 | |
| 2206 | // /mcp (contextual, presentation facet): status maps to Show action. |
| 2207 | let mcp = execute("/mcp status", &mut app); |
| 2208 | assert!( |
| 2209 | matches!( |
| 2210 | mcp.action, |
| 2211 | Some(crate::tui::app::AppAction::Mcp( |
| 2212 | crate::tui::app::McpUiAction::Show |
| 2213 | )) |
| 2214 | ), |
| 2215 | "{mcp:?}" |
| 2216 | ); |
| 2217 | |
| 2218 | // /attach (contextual, workspace + media facets): missing path is a |
| 2219 | // safe error, never a panic, and the composer is untouched. |
| 2220 | let attach = execute("/attach", &mut app); |
| 2221 | assert!(attach.is_error, "{attach:?}"); |
| 2222 | assert!(app.input.is_empty(), "composer must stay unchanged"); |
| 2223 | |
| 2224 | // /network (pure): list produces a message. |
| 2225 | let network = execute("/network list", &mut app); |
| 2226 | assert!(network.message.is_some() || network.is_error, "{network:?}"); |
| 2227 | } |
| 2228 | |
| 2229 | // FEAT-021 project group public dispatch (Phase 6) |
| 2230 | |
| 2231 | #[test] |
| 2232 | fn feat021_project_entries_register_through_portable_bridge() { |
| 2233 | use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler}; |
| 2234 | |
| 2235 | for (name, expected) in [ |
| 2236 | ("init", CommandCapabilities::WORKSPACE), |
| 2237 | ("lsp", CommandCapabilities::PROJECT), |
| 2238 | ("share", CommandCapabilities::PROJECT), |
| 2239 | ( |
| 2240 | "goal", |
| 2241 | CommandCapabilities::PROJECT.union(CommandCapabilities::PRESENTATION), |
| 2242 | ), |
| 2243 | ] { |
| 2244 | assert!( |
| 2245 | registry().has_contextual_handler(name), |
| 2246 | "/{name} must register through the portable bridge" |
| 2247 | ); |
| 2248 | let handler = registry() |
| 2249 | .get(name) |
| 2250 | .expect("entry") |
| 2251 | .contextual_handler() |
| 2252 | .expect("contextual handler"); |
| 2253 | let CommandHandler::Contextual { capabilities, .. } = handler else { |
| 2254 | panic!("/{name} must be contextual"); |
| 2255 | }; |
| 2256 | assert_eq!(capabilities, expected, "/{name} exact capability set"); |
| 2257 | } |
| 2258 | } |
| 2259 | |
| 2260 | #[test] |
| 2261 | fn feat021_project_commands_dispatch_through_public_seam() { |
| 2262 | let mut app = create_test_app(); |
| 2263 | app.workspace = PathBuf::from("."); |
| 2264 | |
| 2265 | // /init: creating message + SendMessage action. |
| 2266 | let init = execute("/init", &mut app); |
| 2267 | assert!(!init.is_error, "{init:?}"); |
| 2268 | assert!(matches!(init.action, Some(AppAction::SendMessage(_)))); |
| 2269 | |
| 2270 | // /lsp status reaches the adapter through the public seam. |
| 2271 | let lsp = execute("/lsp status", &mut app); |
| 2272 | assert!(!lsp.is_error, "{lsp:?}"); |
| 2273 | let lsp_msg = lsp.message.expect("lsp message"); |
| 2274 | assert!( |
| 2275 | lsp_msg.contains("LSP diagnostics are currently **"), |
| 2276 | "{lsp_msg}" |
| 2277 | ); |
| 2278 | |
| 2279 | // /share help is a safe no-op route. |
| 2280 | let share = execute("/share help", &mut app); |
| 2281 | assert!(!share.is_error, "{share:?}"); |
| 2282 | |
| 2283 | // /goal status without a goal prints usage (no panic). |
| 2284 | let goal = execute("/goal status", &mut app); |
| 2285 | assert!(!goal.is_error, "{goal:?}"); |
| 2286 | |
| 2287 | // Metadata bridges to the TUI localization ids. |
| 2288 | for (name, id) in [ |
| 2289 | ("init", MessageId::CmdInitDescription), |
| 2290 | ("lsp", MessageId::CmdLspDescription), |
| 2291 | ("share", MessageId::CmdShareDescription), |
| 2292 | ("goal", MessageId::CmdGoalDescription), |
| 2293 | ] { |
| 2294 | let info = registry().get_info(name).expect("info"); |
| 2295 | assert_eq!(info.description_id, id, "/{name} description bridge"); |
| 2296 | } |
| 2297 | } |
| 2298 | |
| 2299 | #[test] |
| 2300 | fn feat021_public_dispatch_never_panics_on_project_commands() { |
| 2301 | let mut app = create_test_app(); |
| 2302 | app.workspace = PathBuf::from("."); |
| 2303 | for command in [ |
| 2304 | "/init", |
| 2305 | "/init ", |
| 2306 | "/lsp", |
| 2307 | "/lsp status", |
| 2308 | "/lsp on", |
| 2309 | "/lsp off", |
| 2310 | "/lsp bogus", |
| 2311 | "/share", |
| 2312 | "/share help", |
| 2313 | "/share bogus", |
| 2314 | "/goal", |
| 2315 | "/goal status", |
| 2316 | "/goal pause", |
| 2317 | "/goal resume", |
| 2318 | "/goal done", |
| 2319 | "/goal bogus", |
| 2320 | "/goal 42", |
| 2321 | ] { |
| 2322 | let result = execute(command, &mut app); |
| 2323 | // Every path returns a result; none may panic. |
| 2324 | assert!( |
| 2325 | result.message.is_some() || result.action.is_some(), |
| 2326 | "{command}: {result:?}" |
| 2327 | ); |
| 2328 | } |
| 2329 | } |
| 2330 | |
| 2331 | // --------------------------------------------------------------------- |
| 2332 | // FEAT-019: public memory registration/dispatch and exact capability |
| 2333 | // declarations (Task 6.2). Tests enter through the registry and the |
| 2334 | // public `execute` seam and prove the memory group's portable entries. |
| 2335 | // --------------------------------------------------------------------- |
| 2336 | |
| 2337 | /// App with an isolated temp workspace and memory enabled. |
| 2338 | fn memory_test_app(tmpdir: &tempfile::TempDir) -> App { |
| 2339 | let options = TuiOptions { |
| 2340 | memory_path: tmpdir.path().join("memory.md"), |
| 2341 | use_memory: true, |
| 2342 | ..crate::test_support::test_tui_options(tmpdir.path()) |
| 2343 | }; |
| 2344 | App::new(options, &Config::default()) |
| 2345 | } |
| 2346 | |
| 2347 | #[test] |
| 2348 | fn feat019_memory_entries_are_registered_with_exact_capabilities() { |
| 2349 | for (name, expected) in [ |
| 2350 | ( |
| 2351 | "note", |
| 2352 | codewhale_command_contract::handler::CommandCapabilities::WORKSPACE, |
| 2353 | ), |
| 2354 | ( |
| 2355 | "memory", |
| 2356 | codewhale_command_contract::handler::CommandCapabilities::WORKSPACE |
| 2357 | .union(codewhale_command_contract::handler::CommandCapabilities::MEMORY), |
| 2358 | ), |
| 2359 | ] { |
| 2360 | assert!( |
| 2361 | registry().has_contextual_handler(name), |
| 2362 | "/{name} must register through the portable bridge" |
| 2363 | ); |
| 2364 | let handler = registry() |
| 2365 | .get(name) |
| 2366 | .expect("entry") |
| 2367 | .contextual_handler() |
| 2368 | .expect("contextual handler"); |
| 2369 | let codewhale_command_contract::handler::CommandHandler::Contextual { |
| 2370 | capabilities, |
| 2371 | .. |
| 2372 | } = handler |
| 2373 | else { |
| 2374 | panic!("/{name} must be contextual"); |
| 2375 | }; |
| 2376 | assert_eq!(capabilities, expected, "/{name} exact capability set"); |
| 2377 | assert!( |
| 2378 | !capabilities.contains( |
| 2379 | codewhale_command_contract::handler::CommandCapabilities::PRESENTATION |
| 2380 | ) && !capabilities |
| 2381 | .contains(codewhale_command_contract::handler::CommandCapabilities::MEDIA), |
| 2382 | "/{name} must not declare presentation or media" |
| 2383 | ); |
| 2384 | } |
| 2385 | } |
| 2386 | |
| 2387 | // --------------------------------------------------------------------- |
| 2388 | // FEAT-022: skills group registration + public dispatch (Task 6.2). |
| 2389 | // All four commands register through the portable bridge; frontier state |
| 2390 | // is asserted by the migration fixtures and live gate. |
| 2391 | // --------------------------------------------------------------------- |
| 2392 | |
| 2393 | /// Pins HOME to a tempdir so global skill discovery stays hermetic. |
| 2394 | struct Feat022ScopedHome { |
| 2395 | prev: Option<std::ffi::OsString>, |
| 2396 | _home: tempfile::TempDir, |
| 2397 | _guard: crate::test_support::TestEnvLock, |
| 2398 | } |
| 2399 | impl Drop for Feat022ScopedHome { |
| 2400 | fn drop(&mut self) { |
| 2401 | // SAFETY: process-wide lock still held. |
| 2402 | unsafe { |
| 2403 | match self.prev.take() { |
| 2404 | Some(v) => std::env::set_var("HOME", v), |
| 2405 | None => std::env::remove_var("HOME"), |
| 2406 | } |
| 2407 | } |
| 2408 | } |
| 2409 | } |
| 2410 | fn feat022_scoped_home(_tmp: &tempfile::TempDir) -> Feat022ScopedHome { |
| 2411 | let guard = crate::test_support::lock_test_env(); |
| 2412 | let prev = std::env::var_os("HOME"); |
| 2413 | let home = tempfile::TempDir::new().expect("home tempdir"); |
| 2414 | // SAFETY: serialised by the global env lock. |
| 2415 | unsafe { |
| 2416 | std::env::set_var("HOME", home.path()); |
| 2417 | } |
| 2418 | Feat022ScopedHome { |
| 2419 | prev, |
| 2420 | _home: home, |
| 2421 | _guard: guard, |
| 2422 | } |
| 2423 | } |
| 2424 | |
| 2425 | fn feat022_test_app(tmp: &tempfile::TempDir) -> App { |
| 2426 | let mut options = crate::test_support::test_tui_options(tmp.path()); |
| 2427 | options.skills_dir = tmp.path().join("skills"); |
| 2428 | crate::test_support::test_app_with_options(options) |
| 2429 | } |
| 2430 | |
| 2431 | fn feat022_write_skill(dir: &std::path::Path, name: &str) { |
| 2432 | let skill_dir = dir.join(name); |
| 2433 | std::fs::create_dir_all(&skill_dir).unwrap(); |
| 2434 | std::fs::write( |
| 2435 | skill_dir.join("SKILL.md"), |
| 2436 | format!("---\nname: {name}\ndescription: {name} skill\n---\n{name} instructions"), |
| 2437 | ) |
| 2438 | .unwrap(); |
| 2439 | } |
| 2440 | |
| 2441 | #[test] |
| 2442 | fn feat022_all_four_skills_entries_are_registered_with_portable_handlers() { |
| 2443 | use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler}; |
| 2444 | |
| 2445 | for (name, alias, expected) in [ |
| 2446 | ( |
| 2447 | "skills", |
| 2448 | Some("jinengliebiao"), |
| 2449 | CommandCapabilities::SKILL_GROUP, |
| 2450 | ), |
| 2451 | ( |
| 2452 | "skill", |
| 2453 | Some("jineng"), |
| 2454 | CommandCapabilities::SKILL_GROUP.union(CommandCapabilities::SKILLS), |
| 2455 | ), |
| 2456 | ("review", Some("shencha"), CommandCapabilities::SKILL_GROUP), |
| 2457 | ("restore", None, CommandCapabilities::SKILL_GROUP), |
| 2458 | ] { |
| 2459 | let info = registry() |
| 2460 | .get_info(name) |
| 2461 | .unwrap_or_else(|| panic!("/{name} must be registered")); |
| 2462 | assert_eq!(info.name, name, "canonical name"); |
| 2463 | let handler = registry() |
| 2464 | .get(name) |
| 2465 | .expect("entry") |
| 2466 | .contextual_handler() |
| 2467 | .expect("contextual handler"); |
| 2468 | let CommandHandler::Contextual { capabilities, .. } = handler else { |
| 2469 | panic!("/{name} must be contextual"); |
| 2470 | }; |
| 2471 | assert_eq!(capabilities, expected, "/{name} exact capability set"); |
| 2472 | if let Some(alias) = alias { |
| 2473 | assert!( |
| 2474 | registry().get_info(alias).is_some(), |
| 2475 | "/{name} alias {alias} must resolve" |
| 2476 | ); |
| 2477 | } |
| 2478 | } |
| 2479 | } |
| 2480 | |
| 2481 | #[test] |
| 2482 | fn feat019_note_dispatches_through_public_seam() { |
| 2483 | let tmpdir = tempfile::TempDir::new().unwrap(); |
| 2484 | let mut app = memory_test_app(&tmpdir); |
| 2485 | |
| 2486 | let appended = execute("/note hello from dispatch", &mut app); |
| 2487 | assert!(!appended.is_error, "{appended:?}"); |
| 2488 | assert!( |
| 2489 | appended |
| 2490 | .message |
| 2491 | .as_deref() |
| 2492 | .is_some_and(|msg| msg.contains("Note appended to")), |
| 2493 | "{appended:?}" |
| 2494 | ); |
| 2495 | let notes = tmpdir.path().join(".deepseek").join("notes.md"); |
| 2496 | assert!(notes.exists(), "notes file written under the workspace"); |
| 2497 | let content = std::fs::read_to_string(¬es).unwrap(); |
| 2498 | assert!(content.contains("hello from dispatch")); |
| 2499 | |
| 2500 | // Metadata bridges to the TUI localization id. |
| 2501 | let info = registry().get_info("note").expect("note info"); |
| 2502 | assert_eq!( |
| 2503 | info.description_id, |
| 2504 | codewhale_localization::MessageId::CmdNoteDescription |
| 2505 | ); |
| 2506 | } |
| 2507 | |
| 2508 | #[test] |
| 2509 | fn feat019_memory_dispatches_through_public_seam() { |
| 2510 | let tmpdir = tempfile::TempDir::new().unwrap(); |
| 2511 | let mut app = memory_test_app(&tmpdir); |
| 2512 | |
| 2513 | let path = execute("/memory path", &mut app); |
| 2514 | assert!(!path.is_error, "{path:?}"); |
| 2515 | // The native store root is a directory; memory.md is only the legacy |
| 2516 | // import anchor, no longer the authoritative path. |
| 2517 | assert_eq!( |
| 2518 | path.message.as_deref(), |
| 2519 | Some(tmpdir.path().join("memory").to_str().unwrap()) |
| 2520 | ); |
| 2521 | |
| 2522 | // Native status reaches the real adapter through the public seam. |
| 2523 | let status = execute("/memory native status", &mut app); |
| 2524 | assert!(!status.is_error, "{status:?}"); |
| 2525 | let msg = status.message.expect("status message"); |
| 2526 | assert!(msg.contains("Native memory root:"), "{msg}"); |
| 2527 | |
| 2528 | let info = registry().get_info("memory").expect("memory info"); |
| 2529 | assert_eq!( |
| 2530 | info.description_id, |
| 2531 | codewhale_localization::MessageId::CmdMemoryDescription |
| 2532 | ); |
| 2533 | } |
| 2534 | |
| 2535 | #[test] |
| 2536 | fn feat019_public_dispatch_never_panics_on_memory_commands() { |
| 2537 | let tmpdir = tempfile::TempDir::new().unwrap(); |
| 2538 | let mut app = memory_test_app(&tmpdir); |
| 2539 | for command in [ |
| 2540 | "/note", |
| 2541 | "/note ", |
| 2542 | "/memory", |
| 2543 | "/memory native bogus", |
| 2544 | "/memory wat", |
| 2545 | ] { |
| 2546 | let result = execute(command, &mut app); |
| 2547 | // Every path returns a result; none may panic. |
| 2548 | assert!(result.message.is_some(), "{command}: {result:?}"); |
| 2549 | } |
| 2550 | } |
| 2551 | |
| 2552 | #[test] |
| 2553 | fn feat022_skills_commands_dispatch_through_public_seam() { |
| 2554 | let tmp = tempfile::TempDir::new().unwrap(); |
| 2555 | let _home = feat022_scoped_home(&tmp); |
| 2556 | let mut app = feat022_test_app(&tmp); |
| 2557 | std::fs::create_dir_all(tmp.path().join("skills")).unwrap(); |
| 2558 | feat022_write_skill(&tmp.path().join("skills"), "demo"); |
| 2559 | |
| 2560 | // Bare /skills opens Extensions; explicit manage retains the mutation surface. |
| 2561 | let result = execute("/skills", &mut app); |
| 2562 | assert!(!result.is_error, "{result:?}"); |
| 2563 | assert!( |
| 2564 | matches!( |
| 2565 | result.action, |
| 2566 | Some(crate::tui::app::AppAction::OpenExtensions { |
| 2567 | tab: crate::tui::views::extensions::ExtensionsTab::Skills |
| 2568 | }) |
| 2569 | ), |
| 2570 | "{result:?}" |
| 2571 | ); |
| 2572 | |
| 2573 | assert!(matches!( |
| 2574 | execute("/skills manage", &mut app).action, |
| 2575 | Some(AppAction::OpenSkillsManager) |
| 2576 | )); |
| 2577 | let mcp_info = get_command_info("mcp").expect("registered MCP command"); |
| 2578 | assert!(mcp_info.aliases.contains(&"mcps")); |
| 2579 | assert_eq!(get_command_info("mcps").unwrap().name, mcp_info.name); |
| 2580 | for command in ["/mcp", "/mcps"] { |
| 2581 | assert!( |
| 2582 | matches!( |
| 2583 | execute(command, &mut app).action, |
| 2584 | Some(AppAction::OpenExtensions { |
| 2585 | tab: crate::tui::views::extensions::ExtensionsTab::Mcp |
| 2586 | }) |
| 2587 | ), |
| 2588 | "{command}" |
| 2589 | ); |
| 2590 | } |
| 2591 | |
| 2592 | // /skill activates the demo skill and sets active_skill. |
| 2593 | let result = execute("/skill demo", &mut app); |
| 2594 | assert!(!result.is_error, "{result:?}"); |
| 2595 | assert!(result.message.unwrap().contains("Skill 'demo' activated.")); |
| 2596 | assert!(app.active_skill.is_some()); |
| 2597 | |
| 2598 | // /restore with no snapshots shows the empty message. |
| 2599 | let result = execute("/restore", &mut app); |
| 2600 | assert!(!result.is_error, "{result:?}"); |
| 2601 | assert!(result.message.unwrap().contains("No snapshots")); |
| 2602 | |
| 2603 | // /review without a target prints usage. |
| 2604 | let result = execute("/review", &mut app); |
| 2605 | assert!(result.is_error, "{result:?}"); |
| 2606 | assert!(result.message.unwrap().contains("Usage: /review")); |
| 2607 | } |
| 2608 | |
| 2609 | #[test] |
| 2610 | fn feat022_aliases_dispatch_through_public_seam() { |
| 2611 | // All four aliases (jinengliebiao, jineng, shencha) resolve through the |
| 2612 | // registry to the same portable handlers as the canonical names. |
| 2613 | let tmp = tempfile::TempDir::new().unwrap(); |
| 2614 | let _home = feat022_scoped_home(&tmp); |
| 2615 | let mut app = feat022_test_app(&tmp); |
| 2616 | std::fs::create_dir_all(tmp.path().join("skills")).unwrap(); |
| 2617 | feat022_write_skill(&tmp.path().join("skills"), "demo"); |
| 2618 | |
| 2619 | let result = execute("/jinengliebiao", &mut app); |
| 2620 | assert!( |
| 2621 | matches!( |
| 2622 | result.action, |
| 2623 | Some(crate::tui::app::AppAction::OpenExtensions { |
| 2624 | tab: crate::tui::views::extensions::ExtensionsTab::Skills |
| 2625 | }) |
| 2626 | ), |
| 2627 | "{result:?}" |
| 2628 | ); |
| 2629 | |
| 2630 | let result = execute("/jineng demo", &mut app); |
| 2631 | assert!(!result.is_error, "{result:?}"); |
| 2632 | assert!(result.message.unwrap().contains("Skill 'demo' activated.")); |
| 2633 | |
| 2634 | let result = execute("/shencha", &mut app); |
| 2635 | assert!(result.is_error, "{result:?}"); |
| 2636 | assert!(result.message.unwrap().contains("Usage: /review")); |
| 2637 | } |
| 2638 | |
| 2639 | #[test] |
| 2640 | fn feat022_context_exposure_is_exact_per_d4() { |
| 2641 | // The test-only full envelope exposes every adapter; production |
| 2642 | // dispatch exposes only each handler's declared facets. |
| 2643 | // skills/review/restore consume only skill_group; skill also consumes |
| 2644 | // skills for cache refreshes. |
| 2645 | let tmp = tempfile::TempDir::new().unwrap(); |
| 2646 | let _home = feat022_scoped_home(&tmp); |
| 2647 | let mut app = feat022_test_app(&tmp); |
| 2648 | let mut bundle = app.command_contexts(); |
| 2649 | let parts = bundle.parts(); |
| 2650 | assert!(parts.skill_group.is_some()); |
| 2651 | assert!(parts.skills.is_some()); |
| 2652 | // Missing-facet safety through the public seam is covered by the |
| 2653 | // handler-level tests; here we assert the envelope carries both. |
| 2654 | } |
| 2655 | |
| 2656 | // --------------------------------------------------------------------- |
| 2657 | // FEAT-020 plugins group public dispatch (Phase 6) |
| 2658 | // --------------------------------------------------------------------- |
| 2659 | |
| 2660 | /// App with an isolated temp workspace and a discovered plugin bundle. |
| 2661 | fn plugin_test_app(tmpdir: &tempfile::TempDir) -> App { |
| 2662 | // Write a minimal plugin bundle so the registry discovers real data. |
| 2663 | let bundle = tmpdir.path().join(".codewhale/plugins/demo"); |
| 2664 | std::fs::create_dir_all(bundle.join("skills/hello")).unwrap(); |
| 2665 | std::fs::write( |
| 2666 | bundle.join("plugin.toml"), |
| 2667 | "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\ndescription = \"Import spreadsheet data safely\"\n[skills]\npath = \"skills\"\n", |
| 2668 | ) |
| 2669 | .unwrap(); |
| 2670 | std::fs::write( |
| 2671 | bundle.join("skills/hello/SKILL.md"), |
| 2672 | "---\nname: hello\ndescription: hello\n---\nbody\n", |
| 2673 | ) |
| 2674 | .unwrap(); |
| 2675 | let options = TuiOptions { |
| 2676 | ..crate::test_support::test_tui_options(tmpdir.path()) |
| 2677 | }; |
| 2678 | let mut app = App::new(options, &Config::default()); |
| 2679 | let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv(); |
| 2680 | app.plugin_registry = discovery.registry_for_workspace(tmpdir.path()); |
| 2681 | app |
| 2682 | } |
| 2683 | |
| 2684 | #[test] |
| 2685 | fn feat020_plugin_entry_is_registered_with_exact_capabilities() { |
| 2686 | let name = "plugin"; |
| 2687 | assert!( |
| 2688 | registry().has_contextual_handler(name), |
| 2689 | "/{name} must register through the portable bridge" |
| 2690 | ); |
| 2691 | let handler = registry() |
| 2692 | .get(name) |
| 2693 | .expect("entry") |
| 2694 | .contextual_handler() |
| 2695 | .expect("contextual handler"); |
| 2696 | let codewhale_command_contract::handler::CommandHandler::Contextual { |
| 2697 | capabilities, .. |
| 2698 | } = handler |
| 2699 | else { |
| 2700 | panic!("/{name} must be contextual"); |
| 2701 | }; |
| 2702 | let expected = codewhale_command_contract::handler::CommandCapabilities::WORKSPACE |
| 2703 | .union(codewhale_command_contract::handler::CommandCapabilities::PRESENTATION) |
| 2704 | .union(codewhale_command_contract::handler::CommandCapabilities::PLUGIN); |
| 2705 | assert_eq!(capabilities, expected, "/{name} exact capability set"); |
| 2706 | // Undeclared facets stay absent. |
| 2707 | assert!( |
| 2708 | !capabilities.contains(codewhale_command_contract::handler::CommandCapabilities::MEDIA) |
| 2709 | ); |
| 2710 | assert!( |
| 2711 | !capabilities |
| 2712 | .contains(codewhale_command_contract::handler::CommandCapabilities::MEMORY) |
| 2713 | ); |
| 2714 | assert!( |
| 2715 | !capabilities |
| 2716 | .contains(codewhale_command_contract::handler::CommandCapabilities::SKILLS) |
| 2717 | ); |
| 2718 | assert!( |
| 2719 | !capabilities |
| 2720 | .contains(codewhale_command_contract::handler::CommandCapabilities::PROJECT) |
| 2721 | ); |
| 2722 | assert!( |
| 2723 | !capabilities |
| 2724 | .contains(codewhale_command_contract::handler::CommandCapabilities::SKILL_GROUP) |
| 2725 | ); |
| 2726 | } |
| 2727 | |
| 2728 | #[test] |
| 2729 | fn feat020_plugin_dispatches_through_public_seam() { |
| 2730 | let tmpdir = tempfile::TempDir::new().unwrap(); |
| 2731 | let mut app = plugin_test_app(&tmpdir); |
| 2732 | |
| 2733 | // Bare action opens the extensions view (no panic). |
| 2734 | let bare = execute("/plugin", &mut app); |
| 2735 | assert!(bare.action.is_some(), "{bare:?}"); |
| 2736 | |
| 2737 | // List reaches the real adapter through the public seam. |
| 2738 | let list = execute("/plugin list", &mut app); |
| 2739 | assert!(!list.is_error, "{list:?}"); |
| 2740 | let msg = list.message.expect("list message"); |
| 2741 | assert!(msg.contains("demo"), "{msg}"); |
| 2742 | |
| 2743 | // Metadata bridges to the TUI localization id. |
| 2744 | let info = registry().get_info("plugin").expect("plugin info"); |
| 2745 | assert_eq!( |
| 2746 | info.description_id, |
| 2747 | codewhale_localization::MessageId::CmdPluginDescription |
| 2748 | ); |
| 2749 | } |
| 2750 | |
| 2751 | #[test] |
| 2752 | fn feat020_public_dispatch_never_panics_on_plugin_commands() { |
| 2753 | let tmpdir = tempfile::TempDir::new().unwrap(); |
| 2754 | let mut app = plugin_test_app(&tmpdir); |
| 2755 | for command in [ |
| 2756 | "/plugin", |
| 2757 | "/plugin ", |
| 2758 | "/plugin list", |
| 2759 | "/plugin show nope", |
| 2760 | "/plugin validate", |
| 2761 | "/plugin tools", |
| 2762 | "/plugin marketplace", |
| 2763 | "/plugin import kimi", |
| 2764 | "/plugin suggest", |
| 2765 | ] { |
| 2766 | let result = execute(command, &mut app); |
| 2767 | // Every path returns a result; none may panic. |
| 2768 | assert!( |
| 2769 | result.message.is_some() || result.action.is_some(), |
| 2770 | "{command}: {result:?}" |
| 2771 | ); |
| 2772 | } |
| 2773 | } |
| 2774 | |
| 2775 | // ----------------------------------------------------------------------- |
| 2776 | // FEAT-023 Phase 6 (Task 6.2): the nine lifecycle registrations dispatch |
| 2777 | // through the public seam with exact capability declarations. |
| 2778 | // ----------------------------------------------------------------------- |
| 2779 | |
| 2780 | #[test] |
| 2781 | fn feat023_lifecycle_entries_register_through_portable_bridge() { |
| 2782 | use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler}; |
| 2783 | |
| 2784 | for name in ["branch", "fork", "load", "new", "save", "sessions", "tree"] { |
| 2785 | assert!( |
| 2786 | registry().has_contextual_handler(name), |
| 2787 | "/{name} must register through the portable bridge" |
| 2788 | ); |
| 2789 | let handler = registry() |
| 2790 | .get(name) |
| 2791 | .expect("entry") |
| 2792 | .contextual_handler() |
| 2793 | .expect("contextual handler"); |
| 2794 | let CommandHandler::Contextual { capabilities, .. } = handler else { |
| 2795 | panic!("/{name} must be contextual"); |
| 2796 | }; |
| 2797 | assert_eq!( |
| 2798 | capabilities, |
| 2799 | CommandCapabilities::SESSION_LIFECYCLE, |
| 2800 | "/{name} declares lifecycle authority only" |
| 2801 | ); |
| 2802 | } |
| 2803 | // Pure handlers register through the bridge with no host bundle. |
| 2804 | for name in ["compact", "purge"] { |
| 2805 | assert!( |
| 2806 | registry().has_contextual_handler(name), |
| 2807 | "/{name} must register through the portable bridge" |
| 2808 | ); |
| 2809 | let handler = registry() |
| 2810 | .get(name) |
| 2811 | .expect("entry") |
| 2812 | .contextual_handler() |
| 2813 | .expect("pure handler"); |
| 2814 | assert!( |
| 2815 | matches!(handler, CommandHandler::Pure(_)), |
| 2816 | "/{name} must be pure (no host context bundle)" |
| 2817 | ); |
| 2818 | } |
| 2819 | // Out-of-scope session command remains legacy for FEAT-026. |
| 2820 | assert!( |
| 2821 | !registry().has_contextual_handler("structcopy"), |
| 2822 | "/structcopy must stay on the legacy dispatch until its owning FEAT" |
| 2823 | ); |
| 2824 | } |
| 2825 | |
| 2826 | // --------------------------------------------------------------------- |
| 2827 | // FEAT-024: session control entries register through the portable bridge |
| 2828 | // (D3/D6) — five declare SESSION_CONTROL only; `/remote-env` declares |
| 2829 | // control plus presentation; export/structcopy remain legacy. |
| 2830 | // --------------------------------------------------------------------- |
| 2831 | |
| 2832 | #[test] |
| 2833 | fn feat024_control_entries_register_through_portable_bridge() { |
| 2834 | use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler}; |
| 2835 | |
| 2836 | for name in ["relay", "rename", "resume", "rc", "title"] { |
| 2837 | assert!( |
| 2838 | registry().has_contextual_handler(name), |
| 2839 | "/{name} must register through the portable bridge" |
| 2840 | ); |
| 2841 | let handler = registry() |
| 2842 | .get(name) |
| 2843 | .expect("entry") |
| 2844 | .contextual_handler() |
| 2845 | .expect("contextual handler"); |
| 2846 | let CommandHandler::Contextual { capabilities, .. } = handler else { |
| 2847 | panic!("/{name} must be contextual"); |
| 2848 | }; |
| 2849 | assert_eq!( |
| 2850 | capabilities, |
| 2851 | CommandCapabilities::SESSION_CONTROL, |
| 2852 | "/{name} declares control authority only" |
| 2853 | ); |
| 2854 | } |
| 2855 | let handler = registry() |
| 2856 | .get("remote-env") |
| 2857 | .expect("entry") |
| 2858 | .contextual_handler() |
| 2859 | .expect("remote-env handler"); |
| 2860 | let CommandHandler::Contextual { capabilities, .. } = handler else { |
| 2861 | panic!("/remote-env must be contextual"); |
| 2862 | }; |
| 2863 | assert_eq!( |
| 2864 | capabilities, |
| 2865 | CommandCapabilities::SESSION_CONTROL.union(CommandCapabilities::PRESENTATION), |
| 2866 | "/remote-env declares control plus presentation only" |
| 2867 | ); |
| 2868 | // FEAT-026 leaf remains legacy until its owning FEAT. |
| 2869 | assert!( |
| 2870 | !registry().has_contextual_handler("structcopy"), |
| 2871 | "/structcopy must stay on the legacy dispatch" |
| 2872 | ); |
| 2873 | } |
| 2874 | |
| 2875 | #[test] |
| 2876 | fn feat023_lifecycle_commands_dispatch_through_public_seam() { |
| 2877 | let mut app = create_test_app(); |
| 2878 | app.workspace = PathBuf::from("."); |
| 2879 | |
| 2880 | // Pure handlers need no App machinery. |
| 2881 | let compact = execute("/compact the auth refactor", &mut app); |
| 2882 | assert_eq!( |
| 2883 | compact.message.as_deref(), |
| 2884 | Some("Context compaction triggered (focus: the auth refactor)...") |
| 2885 | ); |
| 2886 | assert!(matches!( |
| 2887 | compact.action, |
| 2888 | Some(AppAction::CompactContext { focus: Some(ref f) }) if f == "the auth refactor" |
| 2889 | )); |
| 2890 | let purge = execute("/purge", &mut app); |
| 2891 | assert_eq!( |
| 2892 | purge.message.as_deref(), |
| 2893 | Some("Agent context purge triggered...") |
| 2894 | ); |
| 2895 | assert!(matches!(purge.action, Some(AppAction::PurgeContext))); |
| 2896 | |
| 2897 | // Contextual handler reaches the adapter through the seam; /tree on a |
| 2898 | // bare app reports no active session. |
| 2899 | let tree = execute("/tree", &mut app); |
| 2900 | assert!( |
| 2901 | tree.message |
| 2902 | .as_deref() |
| 2903 | .unwrap_or_default() |
| 2904 | .contains("No active session"), |
| 2905 | "{tree:?}" |
| 2906 | ); |
| 2907 | |
| 2908 | // Subcommand routing and usage errors stay byte-exact. |
| 2909 | let bad = execute("/sessions teleport", &mut app); |
| 2910 | assert!( |
| 2911 | bad.message |
| 2912 | .as_deref() |
| 2913 | .unwrap_or_default() |
| 2914 | .contains("unknown subcommand `teleport`"), |
| 2915 | "{bad:?}" |
| 2916 | ); |
| 2917 | let branch_usage = execute("/branch", &mut app); |
| 2918 | assert!( |
| 2919 | branch_usage |
| 2920 | .message |
| 2921 | .as_deref() |
| 2922 | .unwrap_or_default() |
| 2923 | .starts_with("Usage: /branch <entry_id>"), |
| 2924 | "{branch_usage:?}" |
| 2925 | ); |
| 2926 | } |
| 2927 | |
| 2928 | #[test] |
| 2929 | fn feat024_control_commands_dispatch_through_public_seam() { |
| 2930 | let mut app = create_test_app(); |
| 2931 | app.workspace = PathBuf::from("."); |
| 2932 | |
| 2933 | // /relay composes through the control adapter and emits the bounded |
| 2934 | // SendMessage action; only SESSION_CONTROL is exposed. |
| 2935 | let relay = execute("/relay handoff notes", &mut app); |
| 2936 | assert_eq!( |
| 2937 | relay.message.as_deref(), |
| 2938 | Some("Preparing session relay at .deepseek/handoff.md...") |
| 2939 | ); |
| 2940 | let relay_message = match relay.action { |
| 2941 | Some(AppAction::SendMessage(message)) => message, |
| 2942 | other => panic!("expected SendMessage, got {other:?}"), |
| 2943 | }; |
| 2944 | assert!(relay_message.contains("Create a compact session relay (接力)")); |
| 2945 | assert!(relay_message.contains("- Requested relay focus: handoff notes")); |
| 2946 | |
| 2947 | // /rc status reaches the remote-control service through the facet. |
| 2948 | let rc = execute("/rc status", &mut app); |
| 2949 | assert_eq!(rc.message.as_deref(), Some("Remote control: off")); |
| 2950 | |
| 2951 | // /remote-env bare overview is localized through the presentation |
| 2952 | // facet with the exact source-custody boundary copy. |
| 2953 | let remote_env = execute("/remote-env", &mut app); |
| 2954 | assert!( |
| 2955 | remote_env |
| 2956 | .message |
| 2957 | .as_deref() |
| 2958 | .unwrap_or_default() |
| 2959 | .contains("Hosted Work starts a new environment"), |
| 2960 | "{remote_env:?}" |
| 2961 | ); |
| 2962 | |
| 2963 | // /rename and /title validation boundaries stay exact over the seam. |
| 2964 | let rename = execute("/rename", &mut app); |
| 2965 | assert_eq!( |
| 2966 | rename.message.as_deref(), |
| 2967 | Some("Error: Usage: /rename <new title>") |
| 2968 | ); |
| 2969 | let title = execute("/title", &mut app); |
| 2970 | assert!( |
| 2971 | title |
| 2972 | .message |
| 2973 | .as_deref() |
| 2974 | .unwrap_or_default() |
| 2975 | .contains("Window title: [unset]"), |
| 2976 | "{title:?}" |
| 2977 | ); |
| 2978 | |
| 2979 | // Bare /resume opens the picker through the adapter. |
| 2980 | let resume = execute("/resume", &mut app); |
| 2981 | assert!(!resume.is_error); |
| 2982 | assert!(resume.action.is_none()); |
| 2983 | assert!(resume.message.is_none()); |
| 2984 | } |
| 2985 | |
| 2986 | // --------------------------------------------------------------------- |
| 2987 | // FEAT-025: session export entry registers through the portable bridge |
| 2988 | // (D1/D3/D5). `/export` (alias `/daochu`) declares exactly SESSION_EXPORT; |
| 2989 | // `/structcopy` remains a direct host handler for FEAT-026, so the root |
| 2990 | // `session` frontier stays pending. |
| 2991 | // --------------------------------------------------------------------- |
| 2992 | |
| 2993 | #[test] |
| 2994 | fn feat025_export_entry_registers_through_portable_bridge() { |
| 2995 | use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler}; |
| 2996 | |
| 2997 | assert!( |
| 2998 | registry().has_contextual_handler("export"), |
| 2999 | "/export must register through the portable bridge" |
| 3000 | ); |
| 3001 | assert!( |
| 3002 | registry().has_contextual_handler("daochu"), |
| 3003 | "/daochu must resolve to the same portable bridge entry" |
| 3004 | ); |
| 3005 | |
| 3006 | let handler = registry() |
| 3007 | .get("export") |
| 3008 | .expect("entry") |
| 3009 | .contextual_handler() |
| 3010 | .expect("contextual handler"); |
| 3011 | let CommandHandler::Contextual { capabilities, .. } = handler else { |
| 3012 | panic!("/export must be contextual"); |
| 3013 | }; |
| 3014 | assert_eq!( |
| 3015 | capabilities, |
| 3016 | CommandCapabilities::SESSION_EXPORT, |
| 3017 | "/export declares export authority only" |
| 3018 | ); |
| 3019 | |
| 3020 | // Least authority is catalogue-wide: no other registration may declare |
| 3021 | // the session-export capability. |
| 3022 | let export_declarers: Vec<&str> = registry() |
| 3023 | .iter() |
| 3024 | .filter(|command| { |
| 3025 | command |
| 3026 | .contextual_handler() |
| 3027 | .is_some_and(|handler| match handler { |
| 3028 | CommandHandler::Contextual { capabilities, .. } => { |
| 3029 | capabilities.contains(CommandCapabilities::SESSION_EXPORT) |
| 3030 | } |
| 3031 | CommandHandler::Pure(_) => false, |
| 3032 | }) |
| 3033 | }) |
| 3034 | .map(|command| command.info().name) |
| 3035 | .collect(); |
| 3036 | assert_eq!( |
| 3037 | export_declarers, |
| 3038 | vec!["export"], |
| 3039 | "exactly one registration may declare SESSION_EXPORT" |
| 3040 | ); |
| 3041 | |
| 3042 | // The legacy function registration was removed for export only: the |
| 3043 | // contextual entry has no direct host fallback, while `/structcopy` |
| 3044 | // keeps its concrete-App `FunctionCommand` until FEAT-026. |
| 3045 | let mut app = create_test_app(); |
| 3046 | let legacy = registry() |
| 3047 | .get("export") |
| 3048 | .expect("entry") |
| 3049 | .execute(&mut app, None); |
| 3050 | assert_eq!( |
| 3051 | legacy.message.as_deref(), |
| 3052 | Some("Error: command has no executable handler"), |
| 3053 | "/export must not keep a legacy function registration" |
| 3054 | ); |
| 3055 | assert!( |
| 3056 | !registry().has_contextual_handler("structcopy"), |
| 3057 | "/structcopy must stay on the legacy dispatch until FEAT-026" |
| 3058 | ); |
| 3059 | } |
| 3060 | |
| 3061 | #[test] |
| 3062 | fn feat025_export_registered_handler_fails_safely_without_authority() { |
| 3063 | // The dispatcher builds the envelope from the declared capabilities and |
| 3064 | // calls this exact handler object. A narrower envelope that omits the |
| 3065 | // export facet must return the safe error before parsing or performing |
| 3066 | // any projection, clipboard, recovery, resolution, or write operation. |
| 3067 | let handler = registry() |
| 3068 | .get("export") |
| 3069 | .expect("entry") |
| 3070 | .contextual_handler() |
| 3071 | .expect("contextual handler"); |
| 3072 | let codewhale_command_contract::handler::CommandHandler::Contextual { |
| 3073 | handler: contextual, |
| 3074 | .. |
| 3075 | } = handler |
| 3076 | else { |
| 3077 | panic!("/export must be contextual"); |
| 3078 | }; |
| 3079 | |
| 3080 | for arg in [None, Some("clipboard"), Some("file out.md")] { |
| 3081 | let result = contextual( |
| 3082 | codewhale_command_contract::handler::CommandContexts::empty(), |
| 3083 | arg, |
| 3084 | ); |
| 3085 | assert!(result.is_error, "{arg:?} must fail without authority"); |
| 3086 | assert_eq!( |
| 3087 | result.message.as_deref(), |
| 3088 | Some("Error: Command capability unavailable: session_export"), |
| 3089 | "{arg:?} must keep the exact safe error" |
| 3090 | ); |
| 3091 | assert!(result.action.is_none(), "{arg:?} must produce no action"); |
| 3092 | } |
| 3093 | } |
| 3094 | } |
| 3095 |