返回 CodeWhale
fleet.rs
根目录 / crates / tui / src / commands / groups / core / fleet.rs
1 //! `/fleet` command — the agent fleet behind the session.
2 //!
3 //! Fleet = who is working right now. Bare `/fleet` (and `/fleet roster`)
4 //! opens the familiar roster surface for the selected team; `/fleet setup`
5 //! opens the authoring wizard. `/fleet teams` (other aliases: `fleets`,
6 //! `saved`, `manage`) opens the named-team picker for switching between
7 //! saved configurations — never the primary face.
8 //! `/fleet list|status|interrupt|resume` are control-plane verbs that run
9 //! against the **durable** workspace ledger through the shared contract in
10 //! `codewhale-lane`, exactly as `codewhale fleet …` does (#1888, #4022).
11 //! #5888: the usage line presents only the prioritized core verbs — open the
12 //! team, author it, switch saved teams, watch session workers, ask for help.
13 //! Model-route verbs, saved-route verbs, and the durable control plane stay
14 //! dispatchable and live one level deeper, documented in `/fleet help`,
15 //! instead of all fourteen competing in one usage string.
16 //!
17 //! `/fleet status` used to show the current TUI session's sub-agents. That was
18 //! a different thing wearing the same name: session sub-agents are not the
19 //! durable fleet ledger, and a run started by `codewhale fleet run` never
20 //! appeared. The session view is still reachable as `/fleet workers` (and
21 //! `/subagents`), now labelled as what it is.
22
23 use codewhale_lane::control::operations_for_domain;
24 use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface};
25
26 use crate::commands::traits::{CommandInfo, RegisterCommand};
27 use crate::config::Config;
28 use crate::fleet::control::execute_fleet_control;
29 use crate::tui::app::{App, AppAction};
30 use codewhale_localization::{Locale, MessageId, tr};
31
32 use super::CommandResult;
33
34 /// The verbs `/fleet` advertises up front (#5888): open the team, author it,
35 /// switch saved teams, watch current-session workers, ask for the rest.
36 /// Every other verb stays dispatchable; it is documented one level deeper in
37 /// `/fleet help` instead of competing for attention in the usage line.
38 const PRIMARY_VERBS: [&str; 5] = ["members", "setup", "teams", "workers", "help"];
39
40 const PRIMARY_USAGE: &str = "/fleet [members|setup|teams|workers|help]";
41
42 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
43 name: "fleet",
44 aliases: &[],
45 usage: PRIMARY_USAGE,
46 description_id: MessageId::CmdFleetDescription,
47 };
48
49 pub(in crate::commands) struct FleetCmd;
50
51 fn help_text() -> String {
52 let mut out = format!(
53 "Usage: {PRIMARY_USAGE}\n\n\
54 The fleet is who is working right now. The primary verbs cover the daily loop:\n\
55 /fleet (or /fleet members) opens the roster — each member's role, model, and access; \
56 Enter on a member row opens that member's editor. /fleet setup opens the authoring \
57 wizard. /fleet teams (or fleets/saved/manage) switches between named saved teams. \
58 /fleet workers (and /subagents) shows sub-agents in the current TUI session only, \
59 which is a different set: it does not include durable fleet runs.\n\n\
60 Advanced — team model routes (still typed, one level deeper):\n\
61 /fleet models lists the selected team's models. \
62 /fleet add <provider> <model> [role…] and /fleet remove <provider> <model> edit them.\n\n\
63 Advanced — saved routes:\n\
64 /fleet save persists the current session route into the selected team's operator. \
65 /fleet save-as saves it as a new team.\n\n\
66 Durable runs — these act on the durable .codewhale/fleet.jsonl ledger for this \
67 workspace, the same records `codewhale fleet` reads and writes. the ledger file, \
68 saved rosters, and config tables keep the Fleet name:\n",
69 );
70 for descriptor in operations_for_domain(ControlDomain::Fleet) {
71 out.push_str(&format!(
72 "\n {:<30} {:<6} {}\n CLI: {}\n",
73 descriptor.slash_invocation(),
74 descriptor.authority.as_str(),
75 descriptor.summary,
76 descriptor.cli_invocation
77 ));
78 }
79 out
80 }
81
82 /// Split `"<verb> <rest>"` into the verb and its raw target tail.
83 fn split_verb(arg: Option<&str>) -> Option<(&str, Option<&str>)> {
84 let rest = arg.map(str::trim).filter(|value| !value.is_empty())?;
85 Some(match rest.split_once(char::is_whitespace) {
86 Some((verb, tail)) => (verb, Some(tail.trim())),
87 None => (rest, None),
88 })
89 }
90
91 fn fleet_models_text(app: &App) -> String {
92 use crate::fleet::members::fleet_models;
93 let locale = app.ui_locale;
94 let models = match fleet_models(&app.workspace) {
95 Ok(models) if models.is_empty() => {
96 return tr(locale, MessageId::FleetModelsEmpty).into_owned();
97 }
98 Ok(models) => models,
99 // A selected fleet that cannot be read is named, never shown as
100 // "the session model only".
101 Err(error) => {
102 return tr(locale, MessageId::FleetModelsBroken).replace("{error}", &error.to_string());
103 }
104 };
105 let mut lines = vec![
106 tr(locale, MessageId::FleetModelsHeader)
107 .replace("{fleet}", &models[0].fleet)
108 .replace("{count}", &models.len().to_string()),
109 ];
110 for member in &models {
111 let provider = crate::config::ProviderKind::parse(&member.provider);
112 let facts = provider
113 .and_then(|p| crate::provider_lake::catalog_offering_for_model(p, &member.model))
114 .map(|row| {
115 let mut parts = Vec::new();
116 if let Some(cost) = row.cost.as_ref()
117 && let (Some(input), Some(output)) = (cost.input, cost.output)
118 {
119 parts.push(
120 tr(locale, MessageId::FleetModelsFactPrice)
121 .replace("{input}", &format!("{input:.2}"))
122 .replace("{output}", &format!("{output:.2}")),
123 );
124 }
125 if let Some(limit) = row.limit.as_ref()
126 && let Some(context) = limit.context
127 {
128 parts.push(
129 tr(locale, MessageId::FleetModelsFactContext)
130 .replace("{context}", &(context / 1000).to_string()),
131 );
132 }
133 if row.tool_call == Some(true) {
134 parts.push(tr(locale, MessageId::FleetModelsFactTools).into_owned());
135 }
136 parts.join(" · ")
137 })
138 .filter(|facts| !facts.is_empty())
139 .map(|facts| format!(" · {facts}"))
140 .unwrap_or_default();
141 lines.push(format!(
142 " {}/{} · {}{facts}",
143 member.provider,
144 member.model,
145 member.roles_label()
146 ));
147 }
148 lines.push(tr(locale, MessageId::FleetModelsFooter).into_owned());
149 lines.join("\n")
150 }
151
152 /// Whether `provider_id` names a provider the user has configured — active
153 /// route, explicit `[providers.<id>]` table, or usable credentials — in the
154 /// **live** `config`, the same source the `/provider` and `/model` pickers
155 /// consult. A startup snapshot goes stale after an in-session provider change.
156 fn provider_id_is_configured(app: &App, config: &Config, provider_id: &str) -> bool {
157 let Some(active) = app
158 .provider_identity
159 .as_ref()
160 .filter(|identity| config.verify_provider_identity(identity).is_ok())
161 else {
162 return false;
163 };
164 let Ok(identity) = config.resolve_provider_selection_identity(provider_id) else {
165 return false;
166 };
167 crate::config::provider_is_configured_for_active(config, &identity, active)
168 }
169
170 /// The localized reason `provider` may not enter the team, or `None` when
171 /// it is configured. Shared by `/fleet add` and the picker's ⇧F so a locked
172 /// or unauthenticated provider row is refused on both surfaces alike.
173 #[must_use]
174 pub(crate) fn fleet_provider_rejection(
175 app: &App,
176 config: &Config,
177 provider: &str,
178 ) -> Option<String> {
179 (!provider_id_is_configured(app, config, provider)).then(|| {
180 tr(app.ui_locale, MessageId::FleetAddProviderUnconfigured).replace("{provider}", provider)
181 })
182 }
183
184 /// The localized reason a typed `/fleet add` route is refused when a known
185 /// provider's catalog is non-empty and does not list `model`. The picker
186 /// skips this check: its rows already come from the catalog or a live list.
187 #[must_use]
188 pub(crate) fn fleet_catalog_rejection(
189 locale: Locale,
190 provider: &str,
191 model: &str,
192 ) -> Option<String> {
193 let known = crate::config::ProviderKind::parse(provider)?;
194 let served = crate::provider_lake::all_catalog_models_for_provider(known);
195 (!served.is_empty() && !served.iter().any(|id| id.eq_ignore_ascii_case(model))).then(|| {
196 tr(locale, MessageId::FleetAddModelNotServed)
197 .replace("{provider}", provider)
198 .replace("{model}", model)
199 })
200 }
201
202 /// `/fleet add <provider> <model> [role…]`: parse here; the write happens in
203 /// the UI's `AppAction::FleetAddModel` arm, which holds the live config for
204 /// provider validation and the engine handle for the roster refresh.
205 fn fleet_add(app: &App, target: Option<&str>) -> CommandResult {
206 let mut words = target.unwrap_or_default().split_whitespace();
207 let (Some(provider), Some(model)) = (words.next(), words.next()) else {
208 return CommandResult::error(tr(app.ui_locale, MessageId::FleetAddUsage));
209 };
210 CommandResult::action(AppAction::FleetAddModel {
211 provider: provider.to_string(),
212 model: model.to_string(),
213 roles: words.map(str::to_string).collect(),
214 })
215 }
216
217 fn fleet_remove(app: &App, target: Option<&str>) -> CommandResult {
218 let mut words = target.unwrap_or_default().split_whitespace();
219 let (Some(provider), Some(model)) = (words.next(), words.next()) else {
220 return CommandResult::error(tr(app.ui_locale, MessageId::FleetRemoveUsage));
221 };
222 CommandResult::action(AppAction::FleetRemoveModel {
223 provider: provider.to_string(),
224 model: model.to_string(),
225 })
226 }
227
228 fn run_control(app: &App, operation: ControlOperation, target: Option<&str>) -> CommandResult {
229 let receipt = execute_fleet_control(ControlSurface::Slash, &app.workspace, operation, target);
230 let rendered = receipt.render();
231 if receipt.is_error() {
232 CommandResult::error(rendered)
233 } else {
234 CommandResult::message(rendered)
235 }
236 }
237
238 impl RegisterCommand for FleetCmd {
239 fn info() -> &'static CommandInfo {
240 &COMMAND_INFO
241 }
242
243 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
244 let Some((verb, target)) = split_verb(arg) else {
245 // Primary face: the familiar roster for the selected team.
246 // Named-team switching lives under /fleet teams — never between
247 // the operator and their team.
248 return CommandResult::action(AppAction::OpenFleetRoster);
249 };
250 match verb {
251 "save" | "update" => {
252 // Explicit persistence of the pending session route into the
253 // selected fleet's operator. Only an explicit command can
254 // write a saved fleet after an in-session route change.
255 let message = app.apply_route_save_choice(
256 crate::tui::views::route_save_prompt::RouteSaveChoice::UpdateFleet,
257 );
258 return CommandResult::message(message);
259 }
260 "save-as" | "saveas" => {
261 let message = app.apply_route_save_choice(
262 crate::tui::views::route_save_prompt::RouteSaveChoice::SaveAsNewFleet,
263 );
264 return CommandResult::message(message);
265 }
266 _ => {}
267 }
268 match verb {
269 // The team as models (design §10 F1): what the person added,
270 // provider-exact, with the roles each model fills.
271 "models" | "model" => CommandResult::message(fleet_models_text(app)),
272 "add" => fleet_add(app, target),
273 "remove" | "rm" | "drop" => fleet_remove(app, target),
274 "members" | "member" | "roster" | "roles" | "role" | "profiles" | "profile" => {
275 CommandResult::action(AppAction::OpenFleetRoster)
276 }
277 "setup" | "edit" | "new" => CommandResult::action(AppAction::OpenFleetSetup),
278 // Named saved teams — secondary surface for multi-team pick/switch.
279 // Deliberately not "list": that verb is the durable ledger (#4022).
280 // "fleets" stays as a compatibility spelling for the wire name.
281 "teams" | "team" | "fleets" | "saved" | "manage" => {
282 CommandResult::action(AppAction::OpenFleetList)
283 }
284 // The current-session sub-agent projection, named for what it is.
285 "workers" | "worker" | "agents" | "subagents" => super::core::subagents(app),
286 "help" | "?" => CommandResult::message(help_text()),
287 other => match ControlOperation::parse_verb(ControlDomain::Fleet, other) {
288 Some(operation) => run_control(app, operation, target),
289 None => CommandResult::error(format!(
290 "Unknown /fleet target '{other}'. Use {} — or /fleet help for the full \
291 verb list.",
292 PRIMARY_VERBS[..PRIMARY_VERBS.len() - 1].join(", ")
293 )),
294 },
295 }
296 }
297 }
298
299 #[cfg(test)]
300 mod tests {
301 use super::*;
302 use crate::config::Config;
303 use crate::tui::app::TuiOptions;
304 use std::path::PathBuf;
305
306 fn test_app() -> App {
307 let options = TuiOptions {
308 ..crate::test_support::test_tui_options(PathBuf::from("."))
309 };
310 App::new(options, &Config::default())
311 }
312
313 fn app_in(workspace: PathBuf) -> App {
314 let options = TuiOptions {
315 ..crate::test_support::test_tui_options(workspace.clone())
316 };
317 let mut app = App::new(options, &Config::default());
318 app.workspace = workspace;
319 app
320 }
321
322 fn isolated_workspace() -> (tempfile::TempDir, crate::test_support::EnvVarGuard, PathBuf) {
323 let temp = tempfile::tempdir().expect("tempdir");
324 let home = temp.path().join("home");
325 let guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str());
326 let workspace = temp.path().join("repo");
327 std::fs::create_dir_all(&workspace).expect("workspace");
328 (temp, guard, workspace)
329 }
330
331 #[test]
332 fn fleet_add_and_remove_parse_into_actions_the_ui_applies_with_the_live_config() {
333 let mut app = test_app();
334 let added = FleetCmd::execute(&mut app, Some("add openrouter z-ai/glm-5.3-flash explore"));
335 assert_eq!(
336 added.action,
337 Some(AppAction::FleetAddModel {
338 provider: "openrouter".to_string(),
339 model: "z-ai/glm-5.3-flash".to_string(),
340 roles: vec!["explore".to_string()],
341 })
342 );
343 assert!(!added.is_error && added.message.is_none(), "{added:?}");
344
345 let removed = FleetCmd::execute(&mut app, Some("remove openrouter z-ai/glm-5.3-flash"));
346 assert_eq!(
347 removed.action,
348 Some(AppAction::FleetRemoveModel {
349 provider: "openrouter".to_string(),
350 model: "z-ai/glm-5.3-flash".to_string(),
351 })
352 );
353
354 for arg in ["add openrouter", "add", "remove openrouter", "remove"] {
355 let usage = FleetCmd::execute(&mut app, Some(arg));
356 assert!(usage.is_error && usage.action.is_none(), "{arg}: {usage:?}");
357 assert!(
358 usage
359 .message
360 .as_deref()
361 .unwrap_or_default()
362 .contains("Usage: /fleet"),
363 "{arg}: {usage:?}"
364 );
365 }
366 }
367
368 #[test]
369 fn fleet_models_lists_the_selected_fleet_and_names_a_broken_selection() {
370 let _lock = crate::test_support::lock_test_env();
371 let (_temp, _home, workspace) = isolated_workspace();
372 let mut app = app_in(workspace.clone());
373
374 let empty = FleetCmd::execute(&mut app, Some("models"));
375 assert!(
376 empty
377 .message
378 .as_deref()
379 .unwrap_or_default()
380 .contains("session model only"),
381 "got: {empty:?}"
382 );
383
384 crate::fleet::members::add_fleet_model(
385 &workspace,
386 "openrouter",
387 "z-ai/glm-5.3-flash",
388 &["explore".to_string()],
389 )
390 .expect("add");
391 let listed = FleetCmd::execute(&mut app, Some("models"))
392 .message
393 .unwrap_or_default();
394 assert!(
395 listed.contains("openrouter/z-ai/glm-5.3-flash · explore"),
396 "got: {listed}"
397 );
398 assert!(
399 listed.starts_with("Your team `My fleet` (1 models)"),
400 "got: {listed}"
401 );
402
403 // A selected team whose file is gone is a broken selection, and
404 // `/fleet models` says so instead of "session model only".
405 let selected = crate::fleet::store::resolve_selected_fleet(&workspace)
406 .expect("ok")
407 .expect("selected");
408 std::fs::remove_file(&selected.path).expect("remove");
409 let broken = FleetCmd::execute(&mut app, Some("models"))
410 .message
411 .unwrap_or_default();
412 assert!(broken.contains("could not be loaded"), "got: {broken}");
413 assert!(!broken.contains("session model only"), "got: {broken}");
414 }
415
416 #[test]
417 fn fleet_add_rejections_come_from_the_live_config_and_the_catalog() {
418 let app = test_app();
419 let mut live = Config::default();
420 assert!(
421 fleet_provider_rejection(&app, &live, "unknown-provider")
422 .is_some_and(|reason| reason.contains("not a configured provider"))
423 );
424 assert!(fleet_provider_rejection(&app, &live, "openrouter").is_some());
425 // An in-session credential change is visible without restarting.
426 live.provider_config_for_mut(
427 &live.test_identity_for_kind(crate::config::ProviderKind::Openrouter),
428 )
429 .unwrap()
430 .api_key = Some("test-key".to_string());
431 assert_eq!(fleet_provider_rejection(&app, &live, "openrouter"), None);
432
433 assert!(
434 fleet_catalog_rejection(Locale::En, "anthropic", "not-a-real-model")
435 .is_some_and(|reason| reason.contains("does not serve"))
436 );
437 assert_eq!(
438 fleet_catalog_rejection(Locale::En, "some-custom-provider", "anything"),
439 None
440 );
441 }
442
443 #[test]
444 fn fleet_command_opens_roster_view() {
445 let mut app = test_app();
446
447 let result = FleetCmd::execute(&mut app, None);
448
449 assert_eq!(result.action, Some(AppAction::OpenFleetRoster));
450 assert!(result.message.is_none());
451 }
452
453 #[test]
454 fn fleet_saved_fleet_verbs_open_the_named_fleet_list() {
455 for arg in ["teams", "team", "fleets", "saved", "manage"] {
456 let mut app = test_app();
457
458 let result = FleetCmd::execute(&mut app, Some(arg));
459
460 assert_eq!(result.action, Some(AppAction::OpenFleetList), "{arg}");
461 assert!(result.message.is_none(), "{arg}");
462 }
463 }
464
465 #[test]
466 fn retired_pod_invocations_are_rejected() {
467 let mut app = test_app();
468 let rejected = crate::commands::execute("/pod", &mut app);
469 assert!(
470 rejected.is_error,
471 "/pod must not dispatch, got: {rejected:?}"
472 );
473 assert!(
474 rejected
475 .message
476 .as_deref()
477 .unwrap_or_default()
478 .contains("Unknown command: /pod"),
479 "got: {rejected:?}"
480 );
481
482 let mut app = test_app();
483 let retired_verb = FleetCmd::execute(&mut app, Some("pods"));
484 assert!(retired_verb.is_error);
485 assert!(
486 retired_verb
487 .message
488 .as_deref()
489 .is_some_and(|message| message.contains("Unknown /fleet target 'pods'")),
490 "got: {retired_verb:?}"
491 );
492 }
493
494 #[test]
495 fn fleet_members_and_roster_aliases_open_roster_view() {
496 for arg in [
497 "members", "member", "roster", "roles", "role", "profiles", "profile",
498 ] {
499 let mut app = test_app();
500
501 let result = FleetCmd::execute(&mut app, Some(arg));
502
503 assert_eq!(result.action, Some(AppAction::OpenFleetRoster), "{arg}");
504 assert!(result.message.is_none(), "{arg}");
505 }
506 }
507
508 #[test]
509 fn fleet_setup_args_open_setup_wizard() {
510 for arg in ["setup", "edit", "new"] {
511 let mut app = test_app();
512
513 let result = FleetCmd::execute(&mut app, Some(arg));
514
515 assert_eq!(result.action, Some(AppAction::OpenFleetSetup), "{arg}");
516 assert!(result.message.is_none(), "{arg}");
517 }
518 }
519
520 /// #4022: the session sub-agent projection keeps its own name. It is no
521 /// longer allowed to answer for the durable worker ledger.
522 #[test]
523 fn fleet_workers_arg_opens_the_session_subagent_view() {
524 for arg in ["workers", "worker", "agents", "subagents"] {
525 let mut app = test_app();
526
527 let result = FleetCmd::execute(&mut app, Some(arg));
528
529 assert_eq!(result.action, Some(AppAction::ListSubAgents), "{arg}");
530 assert!(result.message.is_none(), "{arg}");
531 }
532 }
533
534 /// #4022: `/fleet status` must read the durable ledger, not substitute the
535 /// current session's sub-agents for it.
536 #[test]
537 fn fleet_status_reads_the_durable_ledger_not_session_subagents() {
538 let workspace = tempfile::tempdir().unwrap();
539 let mut app = app_in(workspace.path().to_path_buf());
540
541 let result = FleetCmd::execute(&mut app, Some("status"));
542
543 assert_eq!(
544 result.action, None,
545 "/fleet status must not open the session sub-agent view"
546 );
547 let message = result.message.as_deref().unwrap_or_default();
548 assert!(message.contains("fleet.status"), "got: {message}");
549 // This workspace has no ledger, so the truthful answer is a typed
550 // unavailability — never an empty-looking "all clear".
551 assert!(message.contains("no_fleet_ledger"), "got: {message}");
552 assert!(
553 !workspace
554 .path()
555 .join(".codewhale")
556 .join("fleet.jsonl")
557 .exists(),
558 "a read verb must not create the durable ledger"
559 );
560 }
561
562 #[test]
563 fn fleet_control_verbs_route_through_the_shared_contract() {
564 let workspace = tempfile::tempdir().unwrap();
565 for (arg, expected_id) in [
566 ("list", "fleet.list"),
567 ("status", "fleet.status"),
568 ("interrupt worker-1", "fleet.interrupt"),
569 ("resume run-1", "fleet.resume"),
570 ("restart worker-1", "fleet.restart"),
571 ] {
572 let mut app = app_in(workspace.path().to_path_buf());
573 let result = FleetCmd::execute(&mut app, Some(arg));
574 let message = result.message.as_deref().unwrap_or_default();
575 assert!(
576 message.contains(expected_id),
577 "/fleet {arg} must report {expected_id}, got: {message}"
578 );
579 assert_eq!(result.action, None, "/fleet {arg}");
580 }
581 }
582
583 #[test]
584 fn fleet_help_arg_distinguishes_durable_from_session_state() {
585 let mut app = test_app();
586
587 let result = FleetCmd::execute(&mut app, Some("help"));
588
589 assert!(!result.is_error);
590 assert!(result.action.is_none());
591 let message = result.message.as_deref().unwrap_or_default();
592 for surface in [
593 "/fleet members",
594 "/fleet setup",
595 "/fleet teams",
596 "/fleet status",
597 ] {
598 assert!(message.contains(surface), "help must describe {surface}");
599 }
600 assert!(
601 !message.contains("compatibility alias"),
602 "no retired alias may be documented: {message}"
603 );
604 assert!(
605 !message.contains("codewhale pod"),
606 "no retired CLI spelling may be documented: {message}"
607 );
608 for truth in [
609 "current TUI session",
610 "codewhale fleet status",
611 ".codewhale/fleet.jsonl",
612 ] {
613 assert!(message.contains(truth), "help must distinguish {truth}");
614 }
615 for descriptor in operations_for_domain(ControlDomain::Fleet) {
616 assert!(
617 message.contains(descriptor.cli_invocation),
618 "help must name the CLI twin of {}",
619 descriptor.id
620 );
621 }
622 }
623
624 #[test]
625 fn fleet_unknown_arg_reports_error() {
626 let mut app = test_app();
627
628 let result = FleetCmd::execute(&mut app, Some("bogus"));
629
630 assert!(result.is_error);
631 assert!(result.action.is_none());
632 assert!(
633 result
634 .message
635 .as_deref()
636 .is_some_and(|message| message.contains("Unknown /fleet target 'bogus'"))
637 );
638 assert!(
639 result
640 .message
641 .as_deref()
642 .is_some_and(|message| message.contains("Use members, setup, teams"))
643 );
644 }
645
646 #[test]
647 fn fleet_aliases_are_registered_on_command_info() {
648 assert_eq!(FleetCmd::info().name, "fleet");
649 assert!(FleetCmd::info().aliases.is_empty());
650 assert!(FleetCmd::info().usage.contains("teams"));
651 assert!(FleetCmd::info().usage.contains("workers"));
652 // #5888: the usage line is the prioritized core; advanced verbs stay
653 // dispatchable but are documented in /fleet help, not here.
654 for advanced in ["save-as", "interrupt", "resume", "models", "status"] {
655 assert!(
656 !FleetCmd::info().usage.contains(advanced),
657 "usage must not advertise {advanced}"
658 );
659 }
660 assert!(!FleetCmd::info().usage.contains("pods"));
661 }
662
663 #[test]
664 fn fleet_dispatches_and_retired_pod_does_not() {
665 let mut app = test_app();
666 let result = crate::commands::execute("/fleet", &mut app);
667 assert_eq!(result.action, Some(AppAction::OpenFleetRoster));
668 assert!(!result.is_error);
669
670 assert!(crate::commands::get_command_info("pod").is_none());
671 // Model B (2026-09-03): the live collective is the fleet; there is
672 // no separate `/team` command.
673 assert!(crate::commands::get_command_info("team").is_none());
674
675 let workspace = tempfile::tempdir().expect("workspace");
676 let mut fleet_app = app_in(workspace.path().to_path_buf());
677 let mut retired_app = app_in(workspace.path().to_path_buf());
678 let fleet_status = crate::commands::execute("/fleet status", &mut fleet_app);
679 let retired_status = crate::commands::execute("/pod status", &mut retired_app);
680 assert!(retired_status.is_error);
681 assert_ne!(fleet_status.message, retired_status.message);
682 }
683
684 #[test]
685 fn slash_command_and_cli_agree_on_fleet_verb_ids() {
686 for descriptor in operations_for_domain(ControlDomain::Fleet) {
687 assert_eq!(descriptor.slash_command, COMMAND_INFO.name);
688 assert_eq!(descriptor.hotbar_action_id(), "slash.fleet");
689 // #5888: usage presents only the prioritized core; every durable
690 // control verb is documented one level deeper in /fleet help.
691 assert!(
692 help_text().contains(&format!("/fleet {}", descriptor.verb)),
693 "/fleet help must document {} now that usage does not",
694 descriptor.verb
695 );
696 assert!(descriptor.offers(ControlSurface::Cli));
697 }
698 assert!(
699 !COMMAND_INFO.requires_required_argument(),
700 "/fleet must stay directly runnable from the palette and hotbar"
701 );
702 }
703
704 /// #5888: the primary surface is a small, prioritized set, and every
705 /// advanced verb remains named by `/fleet help` — nothing is
706 /// unreachable, it just lives one level deeper.
707 #[test]
708 fn fleet_usage_presents_a_prioritized_core_and_help_keeps_the_rest_reachable() {
709 let verbs: Vec<&str> = FleetCmd::info()
710 .usage
711 .trim_start_matches("/fleet [")
712 .trim_end_matches(']')
713 .split('|')
714 .collect();
715 assert_eq!(
716 verbs, PRIMARY_VERBS,
717 "the usage line is exactly the primary set"
718 );
719
720 let help = help_text();
721 for advanced in [
722 "/fleet models",
723 "/fleet add",
724 "/fleet remove",
725 "/fleet save",
726 "/fleet save-as",
727 ] {
728 assert!(help.contains(advanced), "help must name {advanced}");
729 }
730 for descriptor in operations_for_domain(ControlDomain::Fleet) {
731 assert!(
732 help.contains(&format!("/fleet {}", descriptor.verb)),
733 "help must keep {} reachable",
734 descriptor.verb
735 );
736 }
737 }
738 }
739
739 lines RUST