返回 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::ApiProvider::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 provider_id = provider_id.trim();
158 if provider_id.is_empty() {
159 return false;
160 }
161 if let Some(provider) = crate::config::ApiProvider::parse(provider_id) {
162 return crate::config::provider_is_configured_for_active(
163 config,
164 provider,
165 app.api_provider,
166 );
167 }
168 // Named custom provider: allow the active custom route, or any explicit
169 // `[providers.<name>]` table.
170 if app.api_provider == crate::config::ApiProvider::Custom
171 && app
172 .provider_identity_for_persistence()
173 .eq_ignore_ascii_case(provider_id)
174 {
175 return true;
176 }
177 config.providers.as_ref().is_some_and(|providers| {
178 providers
179 .custom
180 .keys()
181 .any(|name| name.eq_ignore_ascii_case(provider_id))
182 })
183 }
184
185 /// The localized reason `provider` may not enter the team, or `None` when
186 /// it is configured. Shared by `/fleet add` and the picker's ⇧F so a locked
187 /// or unauthenticated provider row is refused on both surfaces alike.
188 #[must_use]
189 pub(crate) fn fleet_provider_rejection(
190 app: &App,
191 config: &Config,
192 provider: &str,
193 ) -> Option<String> {
194 (!provider_id_is_configured(app, config, provider)).then(|| {
195 tr(app.ui_locale, MessageId::FleetAddProviderUnconfigured).replace("{provider}", provider)
196 })
197 }
198
199 /// The localized reason a typed `/fleet add` route is refused when a known
200 /// provider's catalog is non-empty and does not list `model`. The picker
201 /// skips this check: its rows already come from the catalog or a live list.
202 #[must_use]
203 pub(crate) fn fleet_catalog_rejection(
204 locale: Locale,
205 provider: &str,
206 model: &str,
207 ) -> Option<String> {
208 let known = crate::config::ApiProvider::parse(provider)?;
209 let served = crate::provider_lake::all_catalog_models_for_provider(known);
210 (!served.is_empty() && !served.iter().any(|id| id.eq_ignore_ascii_case(model))).then(|| {
211 tr(locale, MessageId::FleetAddModelNotServed)
212 .replace("{provider}", provider)
213 .replace("{model}", model)
214 })
215 }
216
217 /// `/fleet add <provider> <model> [role…]`: parse here; the write happens in
218 /// the UI's `AppAction::FleetAddModel` arm, which holds the live config for
219 /// provider validation and the engine handle for the roster refresh.
220 fn fleet_add(app: &App, target: Option<&str>) -> CommandResult {
221 let mut words = target.unwrap_or_default().split_whitespace();
222 let (Some(provider), Some(model)) = (words.next(), words.next()) else {
223 return CommandResult::error(tr(app.ui_locale, MessageId::FleetAddUsage));
224 };
225 CommandResult::action(AppAction::FleetAddModel {
226 provider: provider.to_string(),
227 model: model.to_string(),
228 roles: words.map(str::to_string).collect(),
229 })
230 }
231
232 fn fleet_remove(app: &App, target: Option<&str>) -> CommandResult {
233 let mut words = target.unwrap_or_default().split_whitespace();
234 let (Some(provider), Some(model)) = (words.next(), words.next()) else {
235 return CommandResult::error(tr(app.ui_locale, MessageId::FleetRemoveUsage));
236 };
237 CommandResult::action(AppAction::FleetRemoveModel {
238 provider: provider.to_string(),
239 model: model.to_string(),
240 })
241 }
242
243 fn run_control(app: &App, operation: ControlOperation, target: Option<&str>) -> CommandResult {
244 let receipt = execute_fleet_control(ControlSurface::Slash, &app.workspace, operation, target);
245 let rendered = receipt.render();
246 if receipt.is_error() {
247 CommandResult::error(rendered)
248 } else {
249 CommandResult::message(rendered)
250 }
251 }
252
253 impl RegisterCommand for FleetCmd {
254 fn info() -> &'static CommandInfo {
255 &COMMAND_INFO
256 }
257
258 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
259 let Some((verb, target)) = split_verb(arg) else {
260 // Primary face: the familiar roster for the selected team.
261 // Named-team switching lives under /fleet teams — never between
262 // the operator and their team.
263 return CommandResult::action(AppAction::OpenFleetRoster);
264 };
265 match verb {
266 "save" | "update" => {
267 // Explicit persistence of the pending session route into the
268 // selected fleet's operator. Only an explicit command can
269 // write a saved fleet after an in-session route change.
270 let message = app.apply_route_save_choice(
271 crate::tui::views::route_save_prompt::RouteSaveChoice::UpdateFleet,
272 );
273 return CommandResult::message(message);
274 }
275 "save-as" | "saveas" => {
276 let message = app.apply_route_save_choice(
277 crate::tui::views::route_save_prompt::RouteSaveChoice::SaveAsNewFleet,
278 );
279 return CommandResult::message(message);
280 }
281 _ => {}
282 }
283 match verb {
284 // The team as models (design §10 F1): what the person added,
285 // provider-exact, with the roles each model fills.
286 "models" | "model" => CommandResult::message(fleet_models_text(app)),
287 "add" => fleet_add(app, target),
288 "remove" | "rm" | "drop" => fleet_remove(app, target),
289 "members" | "member" | "roster" | "roles" | "role" | "profiles" | "profile" => {
290 CommandResult::action(AppAction::OpenFleetRoster)
291 }
292 "setup" | "edit" | "new" => CommandResult::action(AppAction::OpenFleetSetup),
293 // Named saved teams — secondary surface for multi-team pick/switch.
294 // Deliberately not "list": that verb is the durable ledger (#4022).
295 // "fleets" stays as a compatibility spelling for the wire name.
296 "teams" | "team" | "fleets" | "saved" | "manage" => {
297 CommandResult::action(AppAction::OpenFleetList)
298 }
299 // The current-session sub-agent projection, named for what it is.
300 "workers" | "worker" | "agents" | "subagents" => super::core::subagents(app),
301 "help" | "?" => CommandResult::message(help_text()),
302 other => match ControlOperation::parse_verb(ControlDomain::Fleet, other) {
303 Some(operation) => run_control(app, operation, target),
304 None => CommandResult::error(format!(
305 "Unknown /fleet target '{other}'. Use {} — or /fleet help for the full \
306 verb list.",
307 PRIMARY_VERBS[..PRIMARY_VERBS.len() - 1].join(", ")
308 )),
309 },
310 }
311 }
312 }
313
314 #[cfg(test)]
315 mod tests {
316 use super::*;
317 use crate::config::Config;
318 use crate::tui::app::TuiOptions;
319 use std::path::PathBuf;
320
321 fn test_app() -> App {
322 let options = TuiOptions {
323 ..crate::test_support::test_tui_options(PathBuf::from("."))
324 };
325 App::new(options, &Config::default())
326 }
327
328 fn app_in(workspace: PathBuf) -> App {
329 let options = TuiOptions {
330 ..crate::test_support::test_tui_options(workspace.clone())
331 };
332 let mut app = App::new(options, &Config::default());
333 app.workspace = workspace;
334 app
335 }
336
337 fn isolated_workspace() -> (tempfile::TempDir, crate::test_support::EnvVarGuard, PathBuf) {
338 let temp = tempfile::tempdir().expect("tempdir");
339 let home = temp.path().join("home");
340 let guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str());
341 let workspace = temp.path().join("repo");
342 std::fs::create_dir_all(&workspace).expect("workspace");
343 (temp, guard, workspace)
344 }
345
346 #[test]
347 fn fleet_add_and_remove_parse_into_actions_the_ui_applies_with_the_live_config() {
348 let mut app = test_app();
349 let added = FleetCmd::execute(&mut app, Some("add openrouter z-ai/glm-5.3-flash explore"));
350 assert_eq!(
351 added.action,
352 Some(AppAction::FleetAddModel {
353 provider: "openrouter".to_string(),
354 model: "z-ai/glm-5.3-flash".to_string(),
355 roles: vec!["explore".to_string()],
356 })
357 );
358 assert!(!added.is_error && added.message.is_none(), "{added:?}");
359
360 let removed = FleetCmd::execute(&mut app, Some("remove openrouter z-ai/glm-5.3-flash"));
361 assert_eq!(
362 removed.action,
363 Some(AppAction::FleetRemoveModel {
364 provider: "openrouter".to_string(),
365 model: "z-ai/glm-5.3-flash".to_string(),
366 })
367 );
368
369 for arg in ["add openrouter", "add", "remove openrouter", "remove"] {
370 let usage = FleetCmd::execute(&mut app, Some(arg));
371 assert!(usage.is_error && usage.action.is_none(), "{arg}: {usage:?}");
372 assert!(
373 usage
374 .message
375 .as_deref()
376 .unwrap_or_default()
377 .contains("Usage: /fleet"),
378 "{arg}: {usage:?}"
379 );
380 }
381 }
382
383 #[test]
384 fn fleet_models_lists_the_selected_fleet_and_names_a_broken_selection() {
385 let _lock = crate::test_support::lock_test_env();
386 let (_temp, _home, workspace) = isolated_workspace();
387 let mut app = app_in(workspace.clone());
388
389 let empty = FleetCmd::execute(&mut app, Some("models"));
390 assert!(
391 empty
392 .message
393 .as_deref()
394 .unwrap_or_default()
395 .contains("session model only"),
396 "got: {empty:?}"
397 );
398
399 crate::fleet::members::add_fleet_model(
400 &workspace,
401 "openrouter",
402 "z-ai/glm-5.3-flash",
403 &["explore".to_string()],
404 )
405 .expect("add");
406 let listed = FleetCmd::execute(&mut app, Some("models"))
407 .message
408 .unwrap_or_default();
409 assert!(
410 listed.contains("openrouter/z-ai/glm-5.3-flash · explore"),
411 "got: {listed}"
412 );
413 assert!(
414 listed.starts_with("Your team `My fleet` (1 models)"),
415 "got: {listed}"
416 );
417
418 // A selected team whose file is gone is a broken selection, and
419 // `/fleet models` says so instead of "session model only".
420 let selected = crate::fleet::store::resolve_selected_fleet(&workspace)
421 .expect("ok")
422 .expect("selected");
423 std::fs::remove_file(&selected.path).expect("remove");
424 let broken = FleetCmd::execute(&mut app, Some("models"))
425 .message
426 .unwrap_or_default();
427 assert!(broken.contains("could not be loaded"), "got: {broken}");
428 assert!(!broken.contains("session model only"), "got: {broken}");
429 }
430
431 #[test]
432 fn fleet_add_rejections_come_from_the_live_config_and_the_catalog() {
433 let app = test_app();
434 let mut live = Config::default();
435 assert!(
436 fleet_provider_rejection(&app, &live, "unknown-provider")
437 .is_some_and(|reason| reason.contains("not a configured provider"))
438 );
439 assert!(fleet_provider_rejection(&app, &live, "openrouter").is_some());
440 // An in-session credential change is visible without restarting.
441 live.provider_config_for_mut(crate::config::ApiProvider::Openrouter)
442 .api_key = Some("test-key".to_string());
443 assert_eq!(fleet_provider_rejection(&app, &live, "openrouter"), None);
444
445 assert!(
446 fleet_catalog_rejection(Locale::En, "anthropic", "not-a-real-model")
447 .is_some_and(|reason| reason.contains("does not serve"))
448 );
449 assert_eq!(
450 fleet_catalog_rejection(Locale::En, "some-custom-provider", "anything"),
451 None
452 );
453 }
454
455 #[test]
456 fn fleet_command_opens_roster_view() {
457 let mut app = test_app();
458
459 let result = FleetCmd::execute(&mut app, None);
460
461 assert_eq!(result.action, Some(AppAction::OpenFleetRoster));
462 assert!(result.message.is_none());
463 }
464
465 #[test]
466 fn fleet_saved_fleet_verbs_open_the_named_fleet_list() {
467 for arg in ["teams", "team", "fleets", "saved", "manage"] {
468 let mut app = test_app();
469
470 let result = FleetCmd::execute(&mut app, Some(arg));
471
472 assert_eq!(result.action, Some(AppAction::OpenFleetList), "{arg}");
473 assert!(result.message.is_none(), "{arg}");
474 }
475 }
476
477 #[test]
478 fn retired_pod_invocations_are_rejected() {
479 let mut app = test_app();
480 let rejected = crate::commands::execute("/pod", &mut app);
481 assert!(
482 rejected.is_error,
483 "/pod must not dispatch, got: {rejected:?}"
484 );
485 assert!(
486 rejected
487 .message
488 .as_deref()
489 .unwrap_or_default()
490 .contains("Unknown command: /pod"),
491 "got: {rejected:?}"
492 );
493
494 let mut app = test_app();
495 let retired_verb = FleetCmd::execute(&mut app, Some("pods"));
496 assert!(retired_verb.is_error);
497 assert!(
498 retired_verb
499 .message
500 .as_deref()
501 .is_some_and(|message| message.contains("Unknown /fleet target 'pods'")),
502 "got: {retired_verb:?}"
503 );
504 }
505
506 #[test]
507 fn fleet_members_and_roster_aliases_open_roster_view() {
508 for arg in [
509 "members", "member", "roster", "roles", "role", "profiles", "profile",
510 ] {
511 let mut app = test_app();
512
513 let result = FleetCmd::execute(&mut app, Some(arg));
514
515 assert_eq!(result.action, Some(AppAction::OpenFleetRoster), "{arg}");
516 assert!(result.message.is_none(), "{arg}");
517 }
518 }
519
520 #[test]
521 fn fleet_setup_args_open_setup_wizard() {
522 for arg in ["setup", "edit", "new"] {
523 let mut app = test_app();
524
525 let result = FleetCmd::execute(&mut app, Some(arg));
526
527 assert_eq!(result.action, Some(AppAction::OpenFleetSetup), "{arg}");
528 assert!(result.message.is_none(), "{arg}");
529 }
530 }
531
532 /// #4022: the session sub-agent projection keeps its own name. It is no
533 /// longer allowed to answer for the durable worker ledger.
534 #[test]
535 fn fleet_workers_arg_opens_the_session_subagent_view() {
536 for arg in ["workers", "worker", "agents", "subagents"] {
537 let mut app = test_app();
538
539 let result = FleetCmd::execute(&mut app, Some(arg));
540
541 assert_eq!(result.action, Some(AppAction::ListSubAgents), "{arg}");
542 assert!(result.message.is_none(), "{arg}");
543 }
544 }
545
546 /// #4022: `/fleet status` must read the durable ledger, not substitute the
547 /// current session's sub-agents for it.
548 #[test]
549 fn fleet_status_reads_the_durable_ledger_not_session_subagents() {
550 let workspace = tempfile::tempdir().unwrap();
551 let mut app = app_in(workspace.path().to_path_buf());
552
553 let result = FleetCmd::execute(&mut app, Some("status"));
554
555 assert_eq!(
556 result.action, None,
557 "/fleet status must not open the session sub-agent view"
558 );
559 let message = result.message.as_deref().unwrap_or_default();
560 assert!(message.contains("fleet.status"), "got: {message}");
561 // This workspace has no ledger, so the truthful answer is a typed
562 // unavailability — never an empty-looking "all clear".
563 assert!(message.contains("no_fleet_ledger"), "got: {message}");
564 assert!(
565 !workspace
566 .path()
567 .join(".codewhale")
568 .join("fleet.jsonl")
569 .exists(),
570 "a read verb must not create the durable ledger"
571 );
572 }
573
574 #[test]
575 fn fleet_control_verbs_route_through_the_shared_contract() {
576 let workspace = tempfile::tempdir().unwrap();
577 for (arg, expected_id) in [
578 ("list", "fleet.list"),
579 ("status", "fleet.status"),
580 ("interrupt worker-1", "fleet.interrupt"),
581 ("resume run-1", "fleet.resume"),
582 ("restart worker-1", "fleet.restart"),
583 ] {
584 let mut app = app_in(workspace.path().to_path_buf());
585 let result = FleetCmd::execute(&mut app, Some(arg));
586 let message = result.message.as_deref().unwrap_or_default();
587 assert!(
588 message.contains(expected_id),
589 "/fleet {arg} must report {expected_id}, got: {message}"
590 );
591 assert_eq!(result.action, None, "/fleet {arg}");
592 }
593 }
594
595 #[test]
596 fn fleet_help_arg_distinguishes_durable_from_session_state() {
597 let mut app = test_app();
598
599 let result = FleetCmd::execute(&mut app, Some("help"));
600
601 assert!(!result.is_error);
602 assert!(result.action.is_none());
603 let message = result.message.as_deref().unwrap_or_default();
604 for surface in [
605 "/fleet members",
606 "/fleet setup",
607 "/fleet teams",
608 "/fleet status",
609 ] {
610 assert!(message.contains(surface), "help must describe {surface}");
611 }
612 assert!(
613 !message.contains("compatibility alias"),
614 "no retired alias may be documented: {message}"
615 );
616 assert!(
617 !message.contains("codewhale pod"),
618 "no retired CLI spelling may be documented: {message}"
619 );
620 for truth in [
621 "current TUI session",
622 "codewhale fleet status",
623 ".codewhale/fleet.jsonl",
624 ] {
625 assert!(message.contains(truth), "help must distinguish {truth}");
626 }
627 for descriptor in operations_for_domain(ControlDomain::Fleet) {
628 assert!(
629 message.contains(descriptor.cli_invocation),
630 "help must name the CLI twin of {}",
631 descriptor.id
632 );
633 }
634 }
635
636 #[test]
637 fn fleet_unknown_arg_reports_error() {
638 let mut app = test_app();
639
640 let result = FleetCmd::execute(&mut app, Some("bogus"));
641
642 assert!(result.is_error);
643 assert!(result.action.is_none());
644 assert!(
645 result
646 .message
647 .as_deref()
648 .is_some_and(|message| message.contains("Unknown /fleet target 'bogus'"))
649 );
650 assert!(
651 result
652 .message
653 .as_deref()
654 .is_some_and(|message| message.contains("Use members, setup, teams"))
655 );
656 }
657
658 #[test]
659 fn fleet_aliases_are_registered_on_command_info() {
660 assert_eq!(FleetCmd::info().name, "fleet");
661 assert!(FleetCmd::info().aliases.is_empty());
662 assert!(FleetCmd::info().usage.contains("teams"));
663 assert!(FleetCmd::info().usage.contains("workers"));
664 // #5888: the usage line is the prioritized core; advanced verbs stay
665 // dispatchable but are documented in /fleet help, not here.
666 for advanced in ["save-as", "interrupt", "resume", "models", "status"] {
667 assert!(
668 !FleetCmd::info().usage.contains(advanced),
669 "usage must not advertise {advanced}"
670 );
671 }
672 assert!(!FleetCmd::info().usage.contains("pods"));
673 }
674
675 #[test]
676 fn fleet_dispatches_and_retired_pod_does_not() {
677 let mut app = test_app();
678 let result = crate::commands::execute("/fleet", &mut app);
679 assert_eq!(result.action, Some(AppAction::OpenFleetRoster));
680 assert!(!result.is_error);
681
682 assert!(crate::commands::get_command_info("pod").is_none());
683 // Model B (2026-09-03): the live collective is the fleet; there is
684 // no separate `/team` command.
685 assert!(crate::commands::get_command_info("team").is_none());
686
687 let workspace = tempfile::tempdir().expect("workspace");
688 let mut fleet_app = app_in(workspace.path().to_path_buf());
689 let mut retired_app = app_in(workspace.path().to_path_buf());
690 let fleet_status = crate::commands::execute("/fleet status", &mut fleet_app);
691 let retired_status = crate::commands::execute("/pod status", &mut retired_app);
692 assert!(retired_status.is_error);
693 assert_ne!(fleet_status.message, retired_status.message);
694 }
695
696 #[test]
697 fn slash_command_and_cli_agree_on_fleet_verb_ids() {
698 for descriptor in operations_for_domain(ControlDomain::Fleet) {
699 assert_eq!(descriptor.slash_command, COMMAND_INFO.name);
700 assert_eq!(descriptor.hotbar_action_id(), "slash.fleet");
701 // #5888: usage presents only the prioritized core; every durable
702 // control verb is documented one level deeper in /fleet help.
703 assert!(
704 help_text().contains(&format!("/fleet {}", descriptor.verb)),
705 "/fleet help must document {} now that usage does not",
706 descriptor.verb
707 );
708 assert!(descriptor.offers(ControlSurface::Cli));
709 }
710 assert!(
711 !COMMAND_INFO.requires_required_argument(),
712 "/fleet must stay directly runnable from the palette and hotbar"
713 );
714 }
715
716 /// #5888: the primary surface is a small, prioritized set, and every
717 /// advanced verb remains named by `/fleet help` — nothing is
718 /// unreachable, it just lives one level deeper.
719 #[test]
720 fn fleet_usage_presents_a_prioritized_core_and_help_keeps_the_rest_reachable() {
721 let verbs: Vec<&str> = FleetCmd::info()
722 .usage
723 .trim_start_matches("/fleet [")
724 .trim_end_matches(']')
725 .split('|')
726 .collect();
727 assert_eq!(
728 verbs, PRIMARY_VERBS,
729 "the usage line is exactly the primary set"
730 );
731
732 let help = help_text();
733 for advanced in [
734 "/fleet models",
735 "/fleet add",
736 "/fleet remove",
737 "/fleet save",
738 "/fleet save-as",
739 ] {
740 assert!(help.contains(advanced), "help must name {advanced}");
741 }
742 for descriptor in operations_for_domain(ControlDomain::Fleet) {
743 assert!(
744 help.contains(&format!("/fleet {}", descriptor.verb)),
745 "help must keep {} reachable",
746 descriptor.verb
747 );
748 }
749 }
750 }
751
751 lines RUST