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