返回 CodeWhale
mod.rs
根目录 / crates / tui / src / commands / mod.rs
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(&notes).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
3095 lines RUST