返回 CodeWhale
members.rs
根目录 / crates / tui / src / fleet / members.rs
1 //! The fleet as a list of models (design MODEL-ROUTING-CATALOG §10, F1).
2 //!
3 //! A person builds their fleet by adding models from the providers they have
4 //! configured; the operator model later picks sub-agent routes from that list
5 //! only. The store is the selected fleet file (`fleet/store.rs`): its operator
6 //! route and every member that pins an exact `provider` + `model`. Nothing
7 //! here invents a second member store. Shortlist rows carry no role; the
8 //! roles a model fills are the executable member rows that pin it.
9
10 use std::path::Path;
11
12 use super::role::public_role_label;
13 use super::store::{
14 FleetFile, FleetMember, FleetScope, FleetStoreError, load_fleet_at, load_fleet_in_scope,
15 member_pins, provider_ids_match, resolve_selected_fleet, save_fleet, set_selected, slugify,
16 };
17 use codewhale_localization::{Locale, MessageId, tr};
18
19 /// Default name for the personal fleet created by the first `/fleet add` or
20 /// ⇧F on a row in `/model` when no fleet is selected yet.
21 pub const DEFAULT_FLEET_NAME: &str = "My fleet";
22
23 /// One model in the fleet: an exact route plus the roles that pin it.
24 #[derive(Debug, Clone, PartialEq, Eq)]
25 pub struct FleetModel {
26 /// Exact provider id (a `[providers.<id>]` key or a built-in id).
27 pub provider: String,
28 /// Exact model id on that provider's route.
29 pub model: String,
30 /// Roles whose member rows pin this route; `operator` for the fleet's
31 /// own route. Empty when the model was added without a role.
32 pub roles: Vec<String>,
33 /// The fleet this model belongs to.
34 pub fleet: String,
35 }
36
37 impl FleetModel {
38 /// Model wire IDs and custom provider keys are exact. Only known built-in
39 /// provider aliases are interchangeable for membership and row markers.
40 #[must_use]
41 pub fn matches(&self, provider: &str, model: &str) -> bool {
42 provider_ids_match(&self.provider, provider) && self.model == model.trim()
43 }
44
45 /// `roles` joined for a one-line label, or `member` when none.
46 #[must_use]
47 pub fn roles_label(&self) -> String {
48 if self.roles.is_empty() {
49 "member".to_string()
50 } else {
51 self.roles
52 .iter()
53 .map(|role| public_role_label(role))
54 .collect::<Vec<_>>()
55 .join(" · ")
56 }
57 }
58 }
59
60 /// Why the route's membership remains unchanged.
61 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
62 pub enum UnchangedReason {
63 /// The route is the fleet's operator route (your current model while
64 /// this fleet is selected) and has no member row of its own; `⇧F` and a
65 /// role-less `/fleet add` leave it alone instead of duplicating it.
66 OperatorRoute,
67 /// Every requested role already pins the route.
68 AlreadyPresent,
69 }
70
71 /// What a membership change did, for the receipt line.
72 #[derive(Debug, Clone, PartialEq, Eq)]
73 pub enum FleetModelChange {
74 Added {
75 fleet: String,
76 /// The default personal fleet was created for this add.
77 created_fleet: bool,
78 /// The fleet was not selected before this add and is now.
79 selected_fleet: bool,
80 roles: Vec<String>,
81 },
82 Removed {
83 fleet: String,
84 roles: Vec<String>,
85 },
86 /// Route membership is unchanged. A redundant shortlist row may have
87 /// been removed while a saved role or operator still pins the route.
88 Unchanged {
89 fleet: String,
90 reason: UnchangedReason,
91 },
92 }
93
94 /// Why a membership change could not be made. Typed so the surface that
95 /// shows it picks the person's locale; `Display` is the English form for logs.
96 #[derive(Debug)]
97 pub enum FleetModelError {
98 /// Provider id or model id was blank.
99 NeedsRoute,
100 /// No fleet is selected, so there is nothing to remove from.
101 NoSelection,
102 /// The route is the fleet's operator route, which `/fleet save` changes.
103 OperatorRoute { route: String, fleet: String },
104 /// No member row pins the route.
105 NotInFleet { route: String, fleet: String },
106 /// The selected fleet could not be resolved, read, parsed, or written.
107 Store(FleetStoreError),
108 }
109
110 impl From<FleetStoreError> for FleetModelError {
111 fn from(error: FleetStoreError) -> Self {
112 Self::Store(error)
113 }
114 }
115
116 impl FleetModelError {
117 /// The person-facing explanation in `locale`.
118 #[must_use]
119 pub fn message(&self, locale: Locale) -> String {
120 match self {
121 Self::NeedsRoute => tr(locale, MessageId::FleetModelErrorNeedsRoute).into_owned(),
122 Self::NoSelection => tr(locale, MessageId::FleetModelErrorNoSelection).into_owned(),
123 Self::OperatorRoute { route, fleet } => {
124 tr(locale, MessageId::FleetModelErrorOperatorRoute)
125 .replace("{route}", route)
126 .replace("{fleet}", fleet)
127 }
128 Self::NotInFleet { route, fleet } => tr(locale, MessageId::FleetModelErrorNotInFleet)
129 .replace("{route}", route)
130 .replace("{fleet}", fleet),
131 Self::Store(error) => error.to_string(),
132 }
133 }
134 }
135
136 impl std::fmt::Display for FleetModelError {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 f.write_str(&self.message(Locale::En))
139 }
140 }
141
142 impl std::error::Error for FleetModelError {}
143
144 /// The selected fleet's models, operator first, then members in file order.
145 /// Members that inherit the session route (no pin) are not models of their
146 /// own: when the fleet has an operator their role is attributed to that
147 /// route — the route they actually resolve to (#6037) — and without an
148 /// operator they are skipped.
149 ///
150 /// `Ok(empty)` means no fleet is selected (or the selected one has no pinned
151 /// route): the caller states "your fleet is the session model only". `Err`
152 /// means a fleet *is* selected but cannot be resolved or read — a broken
153 /// explicit selection, which must never be shown as "no selection".
154 pub fn fleet_models(workspace: &Path) -> Result<Vec<FleetModel>, FleetStoreError> {
155 let Some(selected) = resolve_selected_fleet(workspace)? else {
156 return Ok(Vec::new());
157 };
158 let (fleet, _scope) = load_fleet_at(&selected.path)?;
159 Ok(models_of(&fleet))
160 }
161
162 /// Project a fleet file into its models with roles unioned per exact route.
163 #[must_use]
164 pub fn models_of(fleet: &FleetFile) -> Vec<FleetModel> {
165 let mut models: Vec<FleetModel> = Vec::new();
166 let mut push = |provider: &str, model: &str, role: Option<&str>| {
167 let role = role.map(str::trim).filter(|r| !r.is_empty());
168 if let Some(existing) = models.iter_mut().find(|m| m.matches(provider, model)) {
169 if let Some(role) = role
170 && !existing.roles.iter().any(|r| r.eq_ignore_ascii_case(role))
171 {
172 existing.roles.push(role.to_string());
173 }
174 return;
175 }
176 models.push(FleetModel {
177 provider: provider.trim().to_string(),
178 model: model.trim().to_string(),
179 roles: role.map(|r| vec![r.to_string()]).unwrap_or_default(),
180 fleet: fleet.name.clone(),
181 });
182 };
183 if let Some(operator) = fleet.operator.as_ref() {
184 push(&operator.provider, &operator.model, Some("operator"));
185 }
186 for member in &fleet.members {
187 match (member.provider.as_deref(), member.model.as_deref()) {
188 (Some(provider), Some(model)) => {
189 push(provider, model, Some(member.role_label()));
190 }
191 // Unpinned = inherit the operator route; the member's role is
192 // covered by that route and follows it when the operator moves.
193 // With no operator the route is the live session's, which this
194 // static projection cannot name — the member stays unlisted.
195 (None, None) if !member.shortlist => {
196 if let Some(operator) = fleet.operator.as_ref() {
197 push(
198 &operator.provider,
199 &operator.model,
200 Some(member.role_label()),
201 );
202 }
203 }
204 _ => {}
205 }
206 }
207 models
208 }
209
210 /// Add `provider/model` to the selected fleet, one member row per role, or
211 /// one explicitly marked shortlist row when no role was requested.
212 ///
213 /// With no fleet selected, the personal [`DEFAULT_FLEET_NAME`] fleet is used —
214 /// loaded when it already exists, created otherwise — and selected only once
215 /// the add has been written. Roles are deduplicated; when every requested
216 /// role already pins the route nothing is rewritten and the change is
217 /// [`FleetModelChange::Unchanged`].
218 ///
219 /// A role member asked to run the fleet's own operator route inherits that
220 /// route instead of pinning it (#6037): the pin would resolve identically
221 /// today and then hold the member on a retired id when the operator moves.
222 pub fn add_fleet_model(
223 workspace: &Path,
224 provider: &str,
225 model: &str,
226 roles: &[String],
227 ) -> Result<FleetModelChange, FleetModelError> {
228 let provider = provider.trim();
229 let model = model.trim();
230 if provider.is_empty() || model.is_empty() {
231 return Err(FleetModelError::NeedsRoute);
232 }
233 let target = selected_or_default(workspace)?;
234 let SelectedOrDefault {
235 mut fleet,
236 scope,
237 created_fleet,
238 needs_select,
239 } = target;
240 let mut unique: Vec<String> = Vec::with_capacity(roles.len());
241 for role in roles.iter().map(|r| r.trim()).filter(|r| !r.is_empty()) {
242 if !unique.iter().any(|seen| seen.eq_ignore_ascii_case(role)) {
243 unique.push(role.to_string());
244 }
245 }
246 let roles = unique;
247 let shortlist = roles.is_empty();
248 let select = |needs_select: bool| -> Result<(), FleetModelError> {
249 if needs_select {
250 set_selected(DEFAULT_FLEET_NAME, FleetScope::Personal, workspace)?;
251 }
252 Ok(())
253 };
254 let on_operator_route = is_operator_route(&fleet, provider, model);
255 if shortlist && on_operator_route {
256 select(needs_select)?;
257 return Ok(FleetModelChange::Unchanged {
258 fleet: fleet.name,
259 reason: UnchangedReason::OperatorRoute,
260 });
261 }
262 // #6037: the pin is the deliberate opt-out, not the default. A role
263 // member asked to run the fleet's own operator route inherits instead —
264 // it resolves to that route today and still follows when the operator
265 // moves. A role asked to run a different route keeps the explicit pin;
266 // a shortlist row always pins (the pin is its entire content).
267 let pins_route = shortlist || !on_operator_route;
268 let model_slug = slugify(model);
269 let mut added_any = false;
270 for role in roles
271 .iter()
272 .map(String::as_str)
273 .chain(shortlist.then_some(""))
274 {
275 let already = fleet.members.iter().any(|m| {
276 if shortlist {
277 return member_pins(m, provider, model);
278 }
279 !m.shortlist
280 && m.role_label().eq_ignore_ascii_case(role)
281 && (member_pins(m, provider, model) || (!pins_route && m.model.is_none()))
282 });
283 if already {
284 continue;
285 }
286 let base = if shortlist {
287 model_slug.clone()
288 } else {
289 slugify(role)
290 };
291 let id = unique_member_id(&fleet, &base, &model_slug);
292 fleet.members.push(FleetMember {
293 id,
294 display_name: None,
295 shortlist,
296 role: role.to_string(),
297 model: pins_route.then(|| model.to_string()),
298 provider: pins_route.then(|| provider.to_string()),
299 reasoning: None,
300 instructions: None,
301 requires: Vec::new(),
302 });
303 added_any = true;
304 }
305 if !added_any {
306 select(needs_select)?;
307 return Ok(FleetModelChange::Unchanged {
308 fleet: fleet.name,
309 reason: UnchangedReason::AlreadyPresent,
310 });
311 }
312 save_fleet(&fleet, scope, workspace)?;
313 select(needs_select)?;
314 Ok(FleetModelChange::Added {
315 fleet: fleet.name,
316 created_fleet,
317 selected_fleet: needs_select,
318 roles,
319 })
320 }
321
322 /// Remove every member row pinning `provider/model` from the selected fleet.
323 /// The operator route is not a member; it is changed with `/fleet save`.
324 pub fn remove_fleet_model(
325 workspace: &Path,
326 provider: &str,
327 model: &str,
328 ) -> Result<FleetModelChange, FleetModelError> {
329 let provider = provider.trim();
330 let model = model.trim();
331 let Some(selected) = resolve_selected_fleet(workspace)? else {
332 return Err(FleetModelError::NoSelection);
333 };
334 let (mut fleet, scope) = load_fleet_at(&selected.path)?;
335 let before = fleet.members.len();
336 let mut roles = Vec::new();
337 fleet.members.retain(|m| {
338 let hit = member_pins(m, provider, model);
339 if hit && !m.role.trim().is_empty() {
340 roles.push(m.role.clone());
341 }
342 !hit
343 });
344 if fleet.members.len() == before {
345 let route = format!("{provider}/{model}");
346 return Err(if is_operator_route(&fleet, provider, model) {
347 FleetModelError::OperatorRoute {
348 route,
349 fleet: fleet.name,
350 }
351 } else {
352 FleetModelError::NotInFleet {
353 route,
354 fleet: fleet.name,
355 }
356 });
357 }
358 save_fleet(&fleet, scope, workspace)?;
359 Ok(FleetModelChange::Removed {
360 fleet: fleet.name,
361 roles,
362 })
363 }
364
365 /// Add when absent, remove when present — the picker's one-key toggle.
366 ///
367 /// The toggle edits only explicitly marked shortlist rows. Saved role pins
368 /// and the operator route remain authoritative and are never removed here.
369 pub fn toggle_fleet_model(
370 workspace: &Path,
371 provider: &str,
372 model: &str,
373 ) -> Result<FleetModelChange, FleetModelError> {
374 let provider = provider.trim();
375 let model = model.trim();
376 let Some(selected) = resolve_selected_fleet(workspace)? else {
377 return add_fleet_model(workspace, provider, model, &[]);
378 };
379 let (mut fleet, scope) = load_fleet_at(&selected.path)?;
380 let before = fleet.members.len();
381 fleet
382 .members
383 .retain(|member| !member.shortlist || !member_pins(member, provider, model));
384 let removed_shortlist = fleet.members.len() != before;
385 if removed_shortlist {
386 save_fleet(&fleet, scope, workspace)?;
387 }
388 let on_operator_route = is_operator_route(&fleet, provider, model);
389 if fleet.members.iter().any(|m| {
390 member_pins(m, provider, model)
391 // #6037: an unpinned role member inherits the operator route, so
392 // it still covers this route even though nothing pins it.
393 || (on_operator_route && !m.shortlist && m.model.is_none())
394 }) {
395 return Ok(FleetModelChange::Unchanged {
396 fleet: fleet.name,
397 reason: UnchangedReason::AlreadyPresent,
398 });
399 }
400 if on_operator_route {
401 return Ok(FleetModelChange::Unchanged {
402 fleet: fleet.name,
403 reason: UnchangedReason::OperatorRoute,
404 });
405 }
406 if removed_shortlist {
407 return Ok(FleetModelChange::Removed {
408 fleet: fleet.name,
409 roles: Vec::new(),
410 });
411 }
412 add_fleet_model(workspace, provider, model, &[])
413 }
414
415 fn is_operator_route(fleet: &FleetFile, provider: &str, model: &str) -> bool {
416 fleet
417 .operator
418 .as_ref()
419 .is_some_and(|op| provider_ids_match(&op.provider, provider) && op.model == model.trim())
420 }
421
422 /// One-line receipt for a membership change, shared by `/fleet add|remove`
423 /// and the picker's `⇧F`.
424 #[must_use]
425 pub fn change_receipt(
426 locale: Locale,
427 provider: &str,
428 model: &str,
429 change: &FleetModelChange,
430 ) -> String {
431 let route = format!("{}/{}", provider.trim(), model.trim());
432 match change {
433 FleetModelChange::Added {
434 fleet,
435 created_fleet,
436 selected_fleet,
437 roles,
438 } => {
439 let line = if roles.is_empty() {
440 tr(locale, MessageId::FleetModelAdded).replace("{route}", &route)
441 } else {
442 tr(locale, MessageId::FleetModelAddedAs)
443 .replace("{route}", &route)
444 .replace("{roles}", &roles.join(", "))
445 };
446 let note = if *created_fleet {
447 tr(locale, MessageId::FleetModelAddedCreatedNote)
448 } else if *selected_fleet {
449 tr(locale, MessageId::FleetModelAddedSelectedNote)
450 } else {
451 std::borrow::Cow::Borrowed("")
452 };
453 format!("{}{note}", line.replace("{fleet}", fleet))
454 }
455 FleetModelChange::Removed { fleet, roles } => {
456 let line = if roles.is_empty() {
457 tr(locale, MessageId::FleetModelRemoved).replace("{route}", &route)
458 } else {
459 tr(locale, MessageId::FleetModelRemovedRoles)
460 .replace("{route}", &route)
461 .replace("{roles}", &roles.join(", "))
462 };
463 line.replace("{fleet}", fleet)
464 }
465 FleetModelChange::Unchanged { fleet, reason } => {
466 let reason = match reason {
467 UnchangedReason::OperatorRoute => {
468 tr(locale, MessageId::FleetModelReasonOperatorRoute)
469 }
470 UnchangedReason::AlreadyPresent => {
471 tr(locale, MessageId::FleetModelReasonAlreadyPresent)
472 }
473 };
474 tr(locale, MessageId::FleetModelUnchanged)
475 .replace("{route}", &route)
476 .replace("{fleet}", fleet)
477 .replace("{reason}", &reason)
478 }
479 }
480 }
481
482 struct SelectedOrDefault {
483 fleet: FleetFile,
484 scope: FleetScope,
485 /// The default personal fleet did not exist and was built in memory; it
486 /// reaches disk only with the first successful add.
487 created_fleet: bool,
488 /// Select the default personal fleet once the add has succeeded.
489 needs_select: bool,
490 }
491
492 /// The selected fleet, or — with nothing selected — the personal default
493 /// fleet: loaded when it already exists (never overwritten with an empty
494 /// one), built in memory otherwise. Nothing is written or selected here.
495 fn selected_or_default(workspace: &Path) -> Result<SelectedOrDefault, FleetStoreError> {
496 if let Some(selected) = resolve_selected_fleet(workspace)? {
497 let (fleet, scope) = load_fleet_at(&selected.path)?;
498 return Ok(SelectedOrDefault {
499 fleet,
500 scope,
501 created_fleet: false,
502 needs_select: false,
503 });
504 }
505 match load_fleet_in_scope(DEFAULT_FLEET_NAME, FleetScope::Personal, workspace) {
506 Ok((fleet, _path)) => Ok(SelectedOrDefault {
507 fleet,
508 scope: FleetScope::Personal,
509 created_fleet: false,
510 needs_select: true,
511 }),
512 Err(FleetStoreError::NotFound(_)) => Ok(SelectedOrDefault {
513 fleet: FleetFile::new(
514 DEFAULT_FLEET_NAME.to_string(),
515 Some("Models added from /models and /fleet add.".to_string()),
516 )?,
517 scope: FleetScope::Personal,
518 created_fleet: true,
519 needs_select: true,
520 }),
521 Err(error) => Err(error),
522 }
523 }
524
525 pub(crate) fn unique_member_id(fleet: &FleetFile, base: &str, model_slug: &str) -> String {
526 let taken = |id: &str| fleet.members.iter().any(|m| m.id.eq_ignore_ascii_case(id));
527 if !taken(base) {
528 return base.to_string();
529 }
530 let with_model = format!("{base}-{model_slug}");
531 if !taken(&with_model) {
532 return with_model;
533 }
534 (2..)
535 .map(|n| format!("{with_model}-{n}"))
536 .find(|candidate| !taken(candidate))
537 .expect("an unbounded counter yields a free id")
538 }
539
540 #[cfg(test)]
541 mod tests {
542 use super::*;
543 use crate::fleet::store::FleetOperator;
544 use crate::fleet::store::{save_fleet, set_selected};
545
546 fn fleet_with(operator: Option<(&str, &str)>, members: &[(&str, &str, &str)]) -> FleetFile {
547 let mut fleet = FleetFile::new("Test".to_string(), None).expect("valid");
548 fleet.operator = operator.map(|(p, m)| FleetOperator {
549 provider: p.to_string(),
550 model: m.to_string(),
551 reasoning: None,
552 });
553 for (id, role, model) in members {
554 fleet.members.push(FleetMember {
555 id: (*id).to_string(),
556 display_name: None,
557 shortlist: false,
558 role: (*role).to_string(),
559 model: Some((*model).to_string()),
560 provider: Some("openrouter".to_string()),
561 reasoning: None,
562 instructions: None,
563 requires: Vec::new(),
564 });
565 }
566 fleet
567 }
568
569 /// An isolated `CODEWHALE_HOME` and workspace; the guard must outlive
570 /// the test body.
571 fn isolated_workspace() -> (
572 tempfile::TempDir,
573 crate::test_support::EnvVarGuard,
574 std::path::PathBuf,
575 ) {
576 let temp = tempfile::tempdir().expect("tempdir");
577 let home = temp.path().join("home");
578 let guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str());
579 let workspace = temp.path().join("repo");
580 std::fs::create_dir_all(&workspace).expect("workspace");
581 (temp, guard, workspace)
582 }
583
584 fn selected_file(workspace: &Path) -> (FleetFile, std::path::PathBuf) {
585 let selected = resolve_selected_fleet(workspace)
586 .expect("ok")
587 .expect("selected");
588 let (fleet, _) = load_fleet_at(&selected.path).expect("load");
589 (fleet, selected.path)
590 }
591
592 #[test]
593 fn models_of_unions_roles_per_exact_route_and_puts_the_operator_first() {
594 let fleet = fleet_with(
595 Some(("openrouter", "z-ai/glm-5.3")),
596 &[
597 ("scout", "scout", "z-ai/glm-5.3-flash"),
598 ("reviewer", "reviewer", "deepseek/deepseek-v4-flash"),
599 ("verifier", "verifier", "z-ai/glm-5.3-flash"),
600 ("planner", "planner", "z-ai/glm-5.3"),
601 ],
602 );
603 let models = models_of(&fleet);
604 let ids: Vec<_> = models.iter().map(|m| m.model.as_str()).collect();
605 assert_eq!(
606 ids,
607 [
608 "z-ai/glm-5.3",
609 "z-ai/glm-5.3-flash",
610 "deepseek/deepseek-v4-flash"
611 ]
612 );
613 assert_eq!(models[0].roles, ["operator", "planner"]);
614 assert_eq!(models[1].roles, ["scout", "verifier"]);
615 assert_eq!(models[1].roles_label(), "explore · test");
616 }
617
618 #[test]
619 fn case_distinct_custom_providers_with_one_model_remain_separate() {
620 let _lock = crate::test_support::lock_test_env();
621 let (_temp, _home, workspace) = isolated_workspace();
622 let model = "shared-wire-model";
623 for provider in ["TeamA", "teama"] {
624 for roles in [Vec::new(), vec!["reviewer".into()]] {
625 assert!(matches!(
626 add_fleet_model(&workspace, provider, model, &roles).unwrap(),
627 FleetModelChange::Added { .. }
628 ));
629 }
630 }
631 let (before, path) = selected_file(&workspace);
632 assert_eq!(before.members.len(), 4);
633 let lower_rows = before
634 .members
635 .iter()
636 .filter(|member| member.provider.as_deref() == Some("teama"))
637 .cloned()
638 .collect::<Vec<_>>();
639 assert_eq!(lower_rows.len(), 2);
640 let models = fleet_models(&workspace).unwrap();
641 assert_eq!(
642 models.len(),
643 2,
644 "both endpoints must remain available to routing"
645 );
646 assert_eq!(models[0].provider, "TeamA");
647 assert_eq!(models[1].provider, "teama");
648 for (index, provider) in ["TeamA", "teama"].into_iter().enumerate() {
649 assert!(models[index].matches(provider, model));
650 assert!(!models[1 - index].matches(provider, model));
651 assert_eq!(models[index].roles, ["reviewer"]);
652 }
653 let bytes = std::fs::read(&path).unwrap();
654 assert!(matches!(
655 remove_fleet_model(&workspace, "TEAMA", model),
656 Err(FleetModelError::NotInFleet { .. })
657 ));
658 assert_eq!(std::fs::read(&path).unwrap(), bytes);
659
660 // Toggle only the upper provider's choice; its role and both lower
661 // provider rows remain. Removal then removes only that upper role.
662 assert!(matches!(
663 toggle_fleet_model(&workspace, "TeamA", model).unwrap(),
664 FleetModelChange::Unchanged {
665 reason: UnchangedReason::AlreadyPresent,
666 ..
667 }
668 ));
669 let after_toggle = selected_file(&workspace).0;
670 assert_eq!(after_toggle.members.len(), 3);
671 assert_eq!(
672 after_toggle
673 .members
674 .iter()
675 .filter(|member| member.provider.as_deref() == Some("teama"))
676 .cloned()
677 .collect::<Vec<_>>(),
678 lower_rows
679 );
680 assert!(matches!(
681 remove_fleet_model(&workspace, "TeamA", model).unwrap(),
682 FleetModelChange::Removed { .. }
683 ));
684 assert_eq!(selected_file(&workspace).0.members, lower_rows);
685 assert!(matches!(
686 toggle_fleet_model(&workspace, "TeamA", model).unwrap(),
687 FleetModelChange::Added { .. }
688 ));
689 assert!(matches!(
690 toggle_fleet_model(&workspace, "TeamA", model).unwrap(),
691 FleetModelChange::Removed { .. }
692 ));
693 assert_eq!(selected_file(&workspace).0.members, lower_rows);
694 }
695
696 #[test]
697 fn custom_provider_case_does_not_alias_the_operator_with_the_same_model() {
698 let _lock = crate::test_support::lock_test_env();
699 let (_temp, _home, workspace) = isolated_workspace();
700 let model = "shared-wire-model";
701 let fleet = fleet_with(Some(("TeamA", model)), &[]);
702 save_fleet(&fleet, FleetScope::Workspace, &workspace).unwrap();
703 set_selected(&fleet.name, FleetScope::Workspace, &workspace).unwrap();
704 assert!(matches!(
705 add_fleet_model(&workspace, "teama", model, &[]).unwrap(),
706 FleetModelChange::Added { .. }
707 ));
708 let models = fleet_models(&workspace).unwrap();
709 assert_eq!(models.len(), 2);
710 assert_eq!(models[0].roles, ["operator"]);
711 assert!(models[1].roles.is_empty());
712 assert!(matches!(
713 toggle_fleet_model(&workspace, "teama", model).unwrap(),
714 FleetModelChange::Removed { .. }
715 ));
716 assert_eq!(selected_file(&workspace).0, fleet);
717 assert!(matches!(
718 add_fleet_model(&workspace, "TeamA", model, &[]).unwrap(),
719 FleetModelChange::Unchanged {
720 reason: UnchangedReason::OperatorRoute,
721 ..
722 }
723 ));
724 }
725
726 #[test]
727 fn built_in_provider_aliases_share_membership_and_operator_pins() {
728 let _lock = crate::test_support::lock_test_env();
729 let (_temp, _home, workspace) = isolated_workspace();
730 let fleet = fleet_with(Some(("meta", "muse-spark-1.3")), &[]);
731 save_fleet(&fleet, FleetScope::Workspace, &workspace).unwrap();
732 set_selected(&fleet.name, FleetScope::Workspace, &workspace).unwrap();
733 assert!(matches!(
734 add_fleet_model(&workspace, "MUSE", "muse-spark-1.3", &[]).unwrap(),
735 FleetModelChange::Unchanged {
736 reason: UnchangedReason::OperatorRoute,
737 ..
738 }
739 ));
740 assert!(matches!(
741 add_fleet_model(&workspace, "meta", "muse-spark-1.2", &[]).unwrap(),
742 FleetModelChange::Added { .. }
743 ));
744 let (_, path) = selected_file(&workspace);
745 let bytes = std::fs::read(&path).unwrap();
746 assert!(matches!(
747 add_fleet_model(&workspace, "muse", "muse-spark-1.2", &[]).unwrap(),
748 FleetModelChange::Unchanged {
749 reason: UnchangedReason::AlreadyPresent,
750 ..
751 }
752 ));
753 assert_eq!(std::fs::read(&path).unwrap(), bytes);
754 let models = fleet_models(&workspace).unwrap();
755 assert_eq!(models.len(), 2);
756 assert!(models[1].matches("MUSE", "muse-spark-1.2"));
757 assert!(matches!(
758 toggle_fleet_model(&workspace, "MUSE", "muse-spark-1.2").unwrap(),
759 FleetModelChange::Removed { .. }
760 ));
761 assert_eq!(selected_file(&workspace).0, fleet);
762 }
763
764 #[test]
765 fn case_distinct_saved_models_survive_add_remove_and_toggle() {
766 let _lock = crate::test_support::lock_test_env();
767 let (_temp, _home, workspace) = isolated_workspace();
768 let upper = "Preview-fixture";
769 let lower = "preview-fixture";
770 for model in [upper, lower] {
771 assert!(matches!(
772 add_fleet_model(&workspace, "openrouter", model, &[]).unwrap(),
773 FleetModelChange::Added { .. }
774 ));
775 assert!(matches!(
776 add_fleet_model(&workspace, "openrouter", model, &["reviewer".into()]).unwrap(),
777 FleetModelChange::Added { .. }
778 ));
779 }
780 let (before, path) = selected_file(&workspace);
781 assert_eq!(before.members.len(), 4);
782 let upper_rows = before
783 .members
784 .iter()
785 .filter(|member| member.model.as_deref() == Some(upper))
786 .cloned()
787 .collect::<Vec<_>>();
788 let lower_rows = before
789 .members
790 .iter()
791 .filter(|member| member.model.as_deref() == Some(lower))
792 .cloned()
793 .collect::<Vec<_>>();
794 assert_eq!(upper_rows.len(), 2);
795 assert_eq!(lower_rows.len(), 2);
796 let models = fleet_models(&workspace).unwrap();
797 assert_eq!(models.len(), 2);
798 for model in [upper, lower] {
799 assert_eq!(
800 models
801 .iter()
802 .filter(|row| row.matches("OPENROUTER", model))
803 .count(),
804 1,
805 "only the exact saved model gets the active marker"
806 );
807 }
808 let bytes = std::fs::read(&path).unwrap();
809 assert!(matches!(
810 add_fleet_model(&workspace, "OPENROUTER", upper, &["reviewer".into()]).unwrap(),
811 FleetModelChange::Unchanged {
812 reason: UnchangedReason::AlreadyPresent,
813 ..
814 }
815 ));
816 assert_eq!(std::fs::read(&path).unwrap(), bytes);
817 assert!(matches!(
818 remove_fleet_model(&workspace, "openrouter", "PREVIEW-FIXTURE"),
819 Err(FleetModelError::NotInFleet { .. })
820 ));
821 assert_eq!(std::fs::read(&path).unwrap(), bytes);
822
823 // Toggle removes only the exact shortlist row and retains its role pin.
824 assert!(matches!(
825 toggle_fleet_model(&workspace, "OPENROUTER", lower).unwrap(),
826 FleetModelChange::Unchanged {
827 reason: UnchangedReason::AlreadyPresent,
828 ..
829 }
830 ));
831 let (after_toggle, _) = selected_file(&workspace);
832 assert_eq!(
833 after_toggle
834 .members
835 .iter()
836 .filter(|member| member.model.as_deref() == Some(upper))
837 .cloned()
838 .collect::<Vec<_>>(),
839 upper_rows
840 );
841 assert_eq!(
842 after_toggle
843 .members
844 .iter()
845 .filter(|member| member.model.as_deref() == Some(lower))
846 .cloned()
847 .collect::<Vec<_>>(),
848 lower_rows
849 .into_iter()
850 .filter(|member| !member.shortlist)
851 .collect::<Vec<_>>()
852 );
853 assert!(matches!(
854 remove_fleet_model(&workspace, "openrouter", lower).unwrap(),
855 FleetModelChange::Removed { .. }
856 ));
857 assert_eq!(selected_file(&workspace).0.members, upper_rows);
858
859 // Toggling the absent lower spelling adds and removes only that choice.
860 assert!(matches!(
861 toggle_fleet_model(&workspace, "openrouter", lower).unwrap(),
862 FleetModelChange::Added { .. }
863 ));
864 assert_eq!(
865 selected_file(&workspace).0.members.len(),
866 upper_rows.len() + 1
867 );
868 assert!(matches!(
869 toggle_fleet_model(&workspace, "openrouter", lower).unwrap(),
870 FleetModelChange::Removed { .. }
871 ));
872 assert_eq!(selected_file(&workspace).0.members, upper_rows);
873 assert!(matches!(
874 remove_fleet_model(&workspace, "openrouter", upper).unwrap(),
875 FleetModelChange::Removed { .. }
876 ));
877 assert!(selected_file(&workspace).0.members.is_empty());
878 }
879
880 #[test]
881 fn case_distinct_member_does_not_alias_the_saved_operator() {
882 let _lock = crate::test_support::lock_test_env();
883 let (_temp, _home, workspace) = isolated_workspace();
884 let upper = "Preview-fixture";
885 let lower = "preview-fixture";
886 let fleet = fleet_with(Some(("openrouter", upper)), &[]);
887 save_fleet(&fleet, FleetScope::Workspace, &workspace).unwrap();
888 set_selected(&fleet.name, FleetScope::Workspace, &workspace).unwrap();
889 assert!(matches!(
890 add_fleet_model(&workspace, "openrouter", lower, &[]).unwrap(),
891 FleetModelChange::Added { .. }
892 ));
893 assert!(matches!(
894 add_fleet_model(&workspace, "OPENROUTER", upper, &[]).unwrap(),
895 FleetModelChange::Unchanged {
896 reason: UnchangedReason::OperatorRoute,
897 ..
898 }
899 ));
900 let models = fleet_models(&workspace).unwrap();
901 assert_eq!(models.len(), 2);
902 assert!(models[0].matches("OPENROUTER", upper));
903 assert!(!models[1].matches("openrouter", upper));
904 assert!(matches!(
905 remove_fleet_model(&workspace, "openrouter", lower).unwrap(),
906 FleetModelChange::Removed { .. }
907 ));
908 assert_eq!(selected_file(&workspace).0.operator, fleet.operator);
909 assert!(matches!(
910 toggle_fleet_model(&workspace, "openrouter", lower).unwrap(),
911 FleetModelChange::Added { .. }
912 ));
913 assert!(matches!(
914 toggle_fleet_model(&workspace, "openrouter", lower).unwrap(),
915 FleetModelChange::Removed { .. }
916 ));
917 assert_eq!(selected_file(&workspace).0, fleet);
918 let (_, path) = selected_file(&workspace);
919 let bytes = std::fs::read(&path).unwrap();
920 assert!(matches!(
921 toggle_fleet_model(&workspace, "openrouter", upper).unwrap(),
922 FleetModelChange::Unchanged {
923 reason: UnchangedReason::OperatorRoute,
924 ..
925 }
926 ));
927 assert!(matches!(
928 remove_fleet_model(&workspace, "openrouter", upper),
929 Err(FleetModelError::OperatorRoute { .. })
930 ));
931 assert_eq!(std::fs::read(&path).unwrap(), bytes);
932 }
933
934 #[test]
935 fn inheriting_members_are_not_models_of_their_own() {
936 let mut fleet = fleet_with(None, &[]);
937 fleet.members.push(FleetMember {
938 id: "builder".to_string(),
939 display_name: None,
940 shortlist: false,
941 role: "builder".to_string(),
942 model: None,
943 provider: None,
944 reasoning: None,
945 instructions: None,
946 requires: Vec::new(),
947 });
948 assert!(models_of(&fleet).is_empty());
949 }
950
951 #[test]
952 fn roleless_add_persists_then_toggle_preserves_explicit_role_pins() {
953 let _lock = crate::test_support::lock_test_env();
954 let (_temp, _home, workspace) = isolated_workspace();
955
956 assert!(fleet_models(&workspace).expect("no selection").is_empty());
957 let change =
958 add_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash", &[]).expect("add");
959 assert_eq!(
960 change,
961 FleetModelChange::Added {
962 fleet: DEFAULT_FLEET_NAME.to_string(),
963 created_fleet: true,
964 selected_fleet: true,
965 roles: Vec::new(),
966 }
967 );
968 let receipt = change_receipt(Locale::En, "openrouter", "z-ai/glm-5.3-flash", &change);
969 assert!(
970 receipt.contains("new personal team, now selected"),
971 "{receipt}"
972 );
973 let models = fleet_models(&workspace).expect("fleet");
974 assert_eq!(models.len(), 1);
975 assert_eq!(models[0].model, "z-ai/glm-5.3-flash");
976 assert!(models[0].roles.is_empty());
977 let (on_disk, _) = selected_file(&workspace);
978 assert_eq!(on_disk.members.len(), 1);
979 assert!(on_disk.members[0].shortlist);
980 assert!(on_disk.members[0].role.is_empty());
981
982 // A second add with another role attaches the role instead of
983 // duplicating the model.
984 add_fleet_model(
985 &workspace,
986 "openrouter",
987 "z-ai/glm-5.3-flash",
988 &["general".to_string(), "scout".to_string()],
989 )
990 .expect("add role");
991 let models = fleet_models(&workspace).expect("fleet");
992 assert_eq!(models.len(), 1);
993 assert_eq!(models[0].roles, ["general", "scout"]);
994 let (before, path) = selected_file(&workspace);
995 let explicit_roles: Vec<_> = before
996 .members
997 .into_iter()
998 .filter(|member| !member.shortlist)
999 .collect();
1000
1001 let change =
1002 toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash").expect("toggle");
1003 assert!(matches!(
1004 change,
1005 FleetModelChange::Unchanged {
1006 reason: UnchangedReason::AlreadyPresent,
1007 ..
1008 }
1009 ));
1010 let receipt = change_receipt(Locale::En, "openrouter", "z-ai/glm-5.3-flash", &change);
1011 assert!(receipt.contains("stays on the team"), "{receipt}");
1012 assert!(
1013 !receipt.contains("Removed"),
1014 "retained role pins must not report removal: {receipt}"
1015 );
1016 let (on_disk, _) = selected_file(&workspace);
1017 assert_eq!(
1018 on_disk.members, explicit_roles,
1019 "explicit pins survive unchanged"
1020 );
1021 let bytes = std::fs::read(&path).expect("saved roles");
1022 let change = toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash")
1023 .expect("pinned toggle");
1024 assert!(matches!(
1025 change,
1026 FleetModelChange::Unchanged {
1027 reason: UnchangedReason::AlreadyPresent,
1028 ..
1029 }
1030 ));
1031 assert_eq!(std::fs::read(&path).expect("unchanged roles"), bytes);
1032
1033 // The explicitly named removal command still removes role assignments.
1034 let change = remove_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash")
1035 .expect("explicit removal");
1036 assert!(
1037 matches!(change, FleetModelChange::Removed { ref roles, .. } if roles == &["general", "scout"])
1038 );
1039 assert!(fleet_models(&workspace).expect("fleet").is_empty());
1040
1041 let err = remove_fleet_model(&workspace, "openrouter", "nope").expect_err("absent");
1042 assert!(
1043 matches!(err, FleetModelError::NotInFleet { ref route, .. } if route == "openrouter/nope"),
1044 "{err:?}"
1045 );
1046 assert!(
1047 err.message(Locale::En).contains("is not on the team"),
1048 "{err}"
1049 );
1050 }
1051
1052 /// The picker's ⇧F creates only a persisted model choice, never a role.
1053 #[test]
1054 fn toggle_enrolls_without_a_role_or_executable_member_and_toggles_off() {
1055 let _lock = crate::test_support::lock_test_env();
1056 let (_temp, _home, workspace) = isolated_workspace();
1057
1058 let change =
1059 toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash").expect("toggle on");
1060 assert!(
1061 matches!(change, FleetModelChange::Added { ref roles, .. } if roles.is_empty()),
1062 "{change:?}"
1063 );
1064 let (on_disk, _) = selected_file(&workspace);
1065 assert_eq!(on_disk.members.len(), 1);
1066 assert!(on_disk.members[0].shortlist);
1067 assert!(on_disk.members[0].role.is_empty());
1068 assert_eq!(on_disk.members[0].provider.as_deref(), Some("openrouter"));
1069 assert_eq!(
1070 on_disk.members[0].model.as_deref(),
1071 Some("z-ai/glm-5.3-flash")
1072 );
1073 assert!(
1074 fleet_models(&workspace).expect("reloaded models")[0]
1075 .roles
1076 .is_empty()
1077 );
1078 let roster = crate::fleet::identity::roster_from_fleet(
1079 &on_disk,
1080 FleetScope::Personal,
1081 Path::new("shortlist.toml"),
1082 );
1083 assert!(roster.members().is_empty(), "a model choice is not a role");
1084 let effective =
1085 crate::fleet::identity::load_effective_roster(&Default::default(), &workspace, None);
1086 assert!(!effective.is_exact_selection());
1087 assert!(
1088 effective
1089 .members()
1090 .iter()
1091 .all(|member| member.origin == crate::fleet::roster::ProfileOrigin::BuiltIn)
1092 );
1093
1094 let change =
1095 toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash").expect("toggle off");
1096 assert!(
1097 matches!(change, FleetModelChange::Removed { ref roles, .. } if roles.is_empty()),
1098 "{change:?}"
1099 );
1100 let (on_disk, _) = selected_file(&workspace);
1101 assert!(on_disk.members.is_empty(), "{:?}", on_disk.members);
1102 }
1103
1104 /// Review finding on #5815: with nothing selected, an existing personal
1105 /// `My fleet` must be loaded and extended, never overwritten with an
1106 /// empty file, and selected only once the add has landed.
1107 #[test]
1108 fn add_with_no_selection_reuses_an_existing_personal_default_fleet() {
1109 let _lock = crate::test_support::lock_test_env();
1110 let (_temp, _home, workspace) = isolated_workspace();
1111 let mut existing = fleet_with(None, &[("scout", "scout", "z-ai/glm-5.3-flash")]);
1112 existing.name = DEFAULT_FLEET_NAME.to_string();
1113 save_fleet(&existing, FleetScope::Personal, &workspace).expect("save");
1114 assert!(resolve_selected_fleet(&workspace).expect("ok").is_none());
1115
1116 // A rejected add selects nothing.
1117 let err = add_fleet_model(&workspace, "", "x", &[]).expect_err("blank provider");
1118 assert!(matches!(err, FleetModelError::NeedsRoute), "{err:?}");
1119 assert!(resolve_selected_fleet(&workspace).expect("ok").is_none());
1120
1121 let change = add_fleet_model(
1122 &workspace,
1123 "openrouter",
1124 "deepseek/deepseek-v4-flash",
1125 &["reviewer".to_string()],
1126 )
1127 .expect("add");
1128 assert_eq!(
1129 change,
1130 FleetModelChange::Added {
1131 fleet: DEFAULT_FLEET_NAME.to_string(),
1132 created_fleet: false,
1133 selected_fleet: true,
1134 roles: vec!["reviewer".to_string()],
1135 }
1136 );
1137 let receipt = change_receipt(
1138 Locale::En,
1139 "openrouter",
1140 "deepseek/deepseek-v4-flash",
1141 &change,
1142 );
1143 assert!(receipt.ends_with("(now selected)"), "{receipt}");
1144 let (on_disk, _) = selected_file(&workspace);
1145 let ids: Vec<_> = on_disk.members.iter().map(|m| m.id.as_str()).collect();
1146 assert_eq!(ids, ["scout", "reviewer"]);
1147 }
1148
1149 /// Review finding on #5815: requested roles are deduplicated and a
1150 /// fully-present request rewrites nothing.
1151 #[test]
1152 fn add_dedupes_roles_and_is_a_no_op_when_every_role_is_present() {
1153 let _lock = crate::test_support::lock_test_env();
1154 let (_temp, _home, workspace) = isolated_workspace();
1155 let change = add_fleet_model(
1156 &workspace,
1157 "openrouter",
1158 "z-ai/glm-5.3-flash",
1159 &[
1160 "scout".to_string(),
1161 "scout".to_string(),
1162 "Scout".to_string(),
1163 ],
1164 )
1165 .expect("add");
1166 assert!(
1167 matches!(change, FleetModelChange::Added { ref roles, .. } if roles == &["scout"]),
1168 "{change:?}"
1169 );
1170 let (on_disk, path) = selected_file(&workspace);
1171 assert_eq!(on_disk.members.len(), 1);
1172 let before = std::fs::read(&path).expect("read");
1173 let before_modified = std::fs::metadata(&path).expect("meta").modified().ok();
1174
1175 let change = add_fleet_model(
1176 &workspace,
1177 "openrouter",
1178 "z-ai/glm-5.3-flash",
1179 &["scout".to_string()],
1180 )
1181 .expect("add again");
1182 assert_eq!(
1183 change,
1184 FleetModelChange::Unchanged {
1185 fleet: DEFAULT_FLEET_NAME.to_string(),
1186 reason: UnchangedReason::AlreadyPresent,
1187 }
1188 );
1189 assert_eq!(std::fs::read(&path).expect("read"), before);
1190 assert_eq!(
1191 std::fs::metadata(&path).expect("meta").modified().ok(),
1192 before_modified,
1193 "a no-op add must not rewrite the file"
1194 );
1195 }
1196
1197 /// Review finding on #5815: a selected fleet that cannot be read is an
1198 /// error, not "no fleet selected".
1199 #[test]
1200 fn fleet_models_reports_a_broken_selection_as_an_error() {
1201 let _lock = crate::test_support::lock_test_env();
1202 let (_temp, _home, workspace) = isolated_workspace();
1203 add_fleet_model(
1204 &workspace,
1205 "openrouter",
1206 "z-ai/glm-5.3-flash",
1207 &["scout".to_string()],
1208 )
1209 .expect("add");
1210 let (_, path) = selected_file(&workspace);
1211
1212 std::fs::write(&path, "this is not = [toml").expect("corrupt");
1213 let err = fleet_models(&workspace).expect_err("parse error");
1214 assert!(matches!(err, FleetStoreError::Parse { .. }), "{err:?}");
1215
1216 std::fs::remove_file(&path).expect("remove");
1217 let err = fleet_models(&workspace).expect_err("dangling selection");
1218 assert!(matches!(err, FleetStoreError::NotFound(_)), "{err:?}");
1219 // The picker's toggle carries the same error instead of adding to a
1220 // fleet nobody can read.
1221 let err = toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash")
1222 .expect_err("toggle on a broken selection");
1223 assert!(matches!(err, FleetModelError::Store(_)), "{err:?}");
1224 }
1225
1226 #[test]
1227 fn legacy_bare_pins_migrate_without_reclassifying_id_only_roles() {
1228 // A 0.9.12 roster: role members plus model pins promoted to members.
1229 let text = r#"
1230 schema = "fleet"
1231 schema_revision = 2
1232 name = "Default"
1233
1234 [[members]]
1235 id = "scout"
1236 role = "scout"
1237 model = "deepseek-v4-flash"
1238 provider = "deepseek"
1239
1240 [[members]]
1241 id = "general"
1242 model = "deepseek-v4-pro"
1243 provider = "deepseek"
1244
1245 [[members]]
1246 id = "audit-team"
1247 model = "private-review-model"
1248 provider = "custom-a"
1249
1250 [[members]]
1251 id = "glm-52"
1252 model = "GLM-5.2"
1253 provider = "zai"
1254
1255 [[members]]
1256 id = "model-a"
1257 model = "model-a"
1258 provider = "custom-a"
1259 "#;
1260 let fleet = FleetFile::parse(text).expect("parse");
1261 let ids: Vec<&str> = fleet.members.iter().map(|m| m.id.as_str()).collect();
1262 assert_eq!(
1263 ids,
1264 vec!["scout", "general", "audit-team"],
1265 "only legacy bare model pins are dropped"
1266 );
1267 assert!(fleet.members.iter().all(|member| !member.shortlist));
1268 assert_eq!(fleet.member("general").unwrap().role_label(), "general");
1269 assert_eq!(
1270 fleet.member("audit-team").unwrap().role_label(),
1271 "audit-team"
1272 );
1273 assert!(!fleet.render_toml().unwrap().contains("shortlist"));
1274 }
1275
1276 #[test]
1277 fn operator_membership_survives_shortlist_toggle_and_explicit_role_pins() {
1278 let _lock = crate::test_support::lock_test_env();
1279 let (_temp, _home, workspace) = isolated_workspace();
1280 let mut fleet = fleet_with(Some(("openrouter", "z-ai/glm-5.3")), &[]);
1281 fleet.name = "Ops".to_string();
1282 fleet.members.push(
1283 serde_json::from_value(serde_json::json!({
1284 "id": "choice", "shortlist": true,
1285 "provider": "openrouter", "model": "z-ai/glm-5.3",
1286 }))
1287 .unwrap(),
1288 );
1289 save_fleet(&fleet, FleetScope::Personal, &workspace).expect("save");
1290 set_selected("Ops", FleetScope::Personal, &workspace).expect("select");
1291
1292 let change = toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3").expect("toggle");
1293 assert_eq!(
1294 change,
1295 FleetModelChange::Unchanged {
1296 fleet: "Ops".to_string(),
1297 reason: UnchangedReason::OperatorRoute,
1298 }
1299 );
1300 let receipt = change_receipt(Locale::En, "openrouter", "z-ai/glm-5.3", &change);
1301 assert!(receipt.contains("stays on the team"), "{receipt}");
1302 assert!(receipt.contains("current model"), "{receipt}");
1303 let change = add_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3", &[])
1304 .expect("operator is already available");
1305 assert!(matches!(
1306 change,
1307 FleetModelChange::Unchanged {
1308 reason: UnchangedReason::OperatorRoute,
1309 ..
1310 }
1311 ));
1312 let (on_disk, _) = selected_file(&workspace);
1313 assert!(
1314 on_disk.members.is_empty(),
1315 "no duplicate member row: {:?}",
1316 on_disk.members
1317 );
1318 let err = remove_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3")
1319 .expect_err("operator route");
1320 assert!(
1321 matches!(err, FleetModelError::OperatorRoute { .. }),
1322 "{err:?}"
1323 );
1324
1325 // With a role, the operator route may also fill that role; the
1326 // shortcut cannot delete either explicit assignment.
1327 add_fleet_model(
1328 &workspace,
1329 "openrouter",
1330 "z-ai/glm-5.3",
1331 &["planner".to_string()],
1332 )
1333 .expect("add role");
1334 let models = fleet_models(&workspace).expect("fleet");
1335 assert_eq!(models.len(), 1);
1336 assert_eq!(models[0].roles, ["operator", "planner"]);
1337 let change = toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3").expect("toggle");
1338 assert!(
1339 matches!(
1340 change,
1341 FleetModelChange::Unchanged {
1342 reason: UnchangedReason::AlreadyPresent,
1343 ..
1344 }
1345 ),
1346 "{change:?}"
1347 );
1348 assert_eq!(
1349 fleet_models(&workspace).expect("fleet")[0].roles,
1350 ["operator", "planner"]
1351 );
1352 }
1353
1354 #[test]
1355 fn add_on_the_operator_route_inherits_and_follows_it_when_it_moves() {
1356 let _lock = crate::test_support::lock_test_env();
1357 let (_temp, _home, workspace) = isolated_workspace();
1358 let mut fleet = fleet_with(Some(("openrouter", "z-ai/glm-5.3")), &[]);
1359 fleet.name = "Ops".to_string();
1360 save_fleet(&fleet, FleetScope::Personal, &workspace).expect("save");
1361 set_selected("Ops", FleetScope::Personal, &workspace).expect("select");
1362
1363 // #6037: a role asked to run the operator route inherits it — no pin.
1364 let change = add_fleet_model(
1365 &workspace,
1366 "openrouter",
1367 "z-ai/glm-5.3",
1368 &["planner".to_string()],
1369 )
1370 .expect("add role");
1371 assert!(
1372 matches!(change, FleetModelChange::Added { .. }),
1373 "{change:?}"
1374 );
1375 let (on_disk, _) = selected_file(&workspace);
1376 let planner = on_disk.member("planner").expect("planner member");
1377 assert_eq!(planner.provider, None, "inherited route writes no pin");
1378 assert_eq!(planner.model, None, "inherited route writes no pin");
1379
1380 // Coverage still counts: a second add of the same role is a no-op.
1381 let change = add_fleet_model(
1382 &workspace,
1383 "openrouter",
1384 "z-ai/glm-5.3",
1385 &["planner".to_string()],
1386 )
1387 .expect("re-add");
1388 assert!(matches!(
1389 change,
1390 FleetModelChange::Unchanged {
1391 reason: UnchangedReason::AlreadyPresent,
1392 ..
1393 }
1394 ));
1395
1396 // The inherited role follows the operator when it moves — the exact
1397 // property a pin would have defeated.
1398 let (mut moved, _) = selected_file(&workspace);
1399 moved.operator = Some(FleetOperator {
1400 provider: "deepseek".to_string(),
1401 model: "deepseek-v4-flash".to_string(),
1402 reasoning: None,
1403 });
1404 let models = models_of(&moved);
1405 assert_eq!(models[0].model, "deepseek-v4-flash");
1406 assert_eq!(models[0].roles, ["operator", "planner"]);
1407
1408 // A role asked to run a different route keeps the deliberate pin.
1409 add_fleet_model(
1410 &workspace,
1411 "openrouter",
1412 "z-ai/glm-5.3-flash",
1413 &["scout".to_string()],
1414 )
1415 .expect("add pinned role");
1416 let (on_disk, _) = selected_file(&workspace);
1417 let scout = on_disk.member("scout").expect("scout member");
1418 assert_eq!(scout.provider.as_deref(), Some("openrouter"));
1419 assert_eq!(scout.model.as_deref(), Some("z-ai/glm-5.3-flash"));
1420 }
1421
1422 #[test]
1423 fn member_ids_stay_unique_when_a_role_is_reused_on_two_models() {
1424 let _lock = crate::test_support::lock_test_env();
1425 let (_temp, _home, workspace) = isolated_workspace();
1426 add_fleet_model(&workspace, "openrouter", "a/one", &["scout".to_string()]).expect("one");
1427 add_fleet_model(&workspace, "openrouter", "a/two", &["scout".to_string()]).expect("two");
1428 let (fleet, _) = selected_file(&workspace);
1429 let ids: Vec<_> = fleet.members.iter().map(|m| m.id.as_str()).collect();
1430 assert_eq!(ids, ["scout", "scout-atwo"]);
1431 }
1432 }
1433
1433 lines RUST