返回 CodeWhale
identity.rs
根目录 / crates / tui / src / fleet / identity.rs
1 //! Effective Fleet roster loading and deterministic member identity.
2 //!
3 //! A selected v2 Fleet is the runtime source of truth. Legacy profile layers
4 //! are consulted only when no Fleet is selected. The selector resolver feeds
5 //! durable Fleet task dispatch and explicit saved-profile agent spawns.
6
7 use std::path::Path;
8
9 use serde::Serialize;
10 use thiserror::Error;
11
12 use codewhale_config::{
13 FleetConfigToml, FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions,
14 FleetRole, FleetSlot,
15 };
16
17 use super::profile::AgentProfile;
18 use super::role::public_role_label;
19 use super::roster::{FleetRoster, ProfileOrigin};
20 use super::store::{
21 FleetFile, FleetScope, MemberCapability, load_fleet_at, resolve_selected_fleet,
22 };
23
24 const MAX_IDENTITY_FIELD_CHARS: usize = 160;
25
26 /// Load the one roster the session must display and dispatch against.
27 ///
28 /// A broken explicit selection becomes a failed roster. It must never be
29 /// indistinguishable from "no selection", which is the only state allowed to
30 /// fall back to the legacy profile merge.
31 #[must_use]
32 pub fn load_effective_roster(
33 fleet_config: &FleetConfigToml,
34 workspace: &Path,
35 plugins: Option<&crate::plugins::PluginRegistry>,
36 ) -> FleetRoster {
37 let selected = match resolve_selected_fleet(workspace) {
38 Ok(selected) => selected,
39 Err(_) => {
40 return FleetRoster::failed(
41 "Selected Fleet is missing or unreadable; inspect /fleet and repair or clear the selection.",
42 );
43 }
44 };
45 let Some(selected) = selected else {
46 return plugins.map_or_else(
47 || FleetRoster::load(fleet_config, workspace),
48 |plugins| FleetRoster::load_with_plugins(fleet_config, workspace, plugins),
49 );
50 };
51 let (fleet, _) = match load_fleet_at(&selected.path) {
52 Ok(loaded) => loaded,
53 Err(_) => {
54 let name = bounded_fleet_label(&selected.name);
55 return FleetRoster::failed(format!(
56 "Selected {} Fleet `{name}` is invalid or unreadable; inspect /fleet and repair or clear the selection.",
57 selected.scope.label()
58 ));
59 }
60 };
61 if fleet.operator.is_none() && fleet.members.iter().all(|member| member.shortlist) {
62 return plugins.map_or_else(
63 || FleetRoster::load(fleet_config, workspace),
64 |plugins| FleetRoster::load_with_plugins(fleet_config, workspace, plugins),
65 );
66 }
67 roster_from_fleet(&fleet, selected.scope, &selected.path)
68 }
69
70 /// Project a validated v2 Fleet into the existing Agent-profile runtime.
71 #[must_use]
72 pub fn roster_from_fleet(fleet: &FleetFile, scope: FleetScope, source: &Path) -> FleetRoster {
73 let origin = match scope {
74 FleetScope::Personal => ProfileOrigin::Personal,
75 FleetScope::Workspace => ProfileOrigin::Workspace,
76 };
77 let operator = fleet.operator.as_ref();
78 FleetRoster::from_members(
79 fleet
80 .members
81 .iter()
82 .filter(|member| !member.shortlist)
83 .map(|member| {
84 let role = member.role.trim();
85 let role = if role.is_empty() {
86 member.id.trim().to_string()
87 } else {
88 public_role_label(role)
89 };
90 // A selected Fleet is one atomic routing policy. An explicit
91 // member pair wins; otherwise the Fleet operator pair is the
92 // child fallback, and only an operator-less Fleet inherits
93 // the current session route. Keep provider/model together so
94 // neither half can leak across providers.
95 let (provider, model) = match (&member.provider, &member.model) {
96 (Some(provider), Some(model)) => (Some(provider.clone()), Some(model.clone())),
97 (None, None) => operator.map_or((None, None), |operator| {
98 (
99 Some(operator.provider.clone()),
100 Some(operator.model.clone()),
101 )
102 }),
103 // FleetFile::validate rejects partial member pins before
104 // this projection is reachable. Keep the fallback atomic
105 // if an in-process caller supplies an unchecked document.
106 _ => (None, None),
107 };
108 let reasoning_effort = member
109 .reasoning
110 .clone()
111 .or_else(|| operator.and_then(|operator| operator.reasoning.clone()));
112 AgentProfile {
113 id: member.id.trim().to_string(),
114 display_name: member.display_name.clone(),
115 description: None,
116 requires: member.requires.clone(),
117 profile: FleetProfile {
118 slot: FleetSlot::from_name(&role),
119 role: FleetRole {
120 name: role,
121 description: None,
122 instructions: member.instructions.clone(),
123 },
124 loadout: FleetLoadout::Inherit,
125 model,
126 provider,
127 reasoning_effort,
128 permissions: FleetProfilePermissions::default(),
129 delegation: FleetDelegationHints::default(),
130 },
131 source: source.to_path_buf(),
132 origin,
133 plugin_authority: None,
134 }
135 })
136 .collect(),
137 )
138 }
139
140 /// Bounded identity row returned by `agent action=roster`.
141 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
142 pub struct FleetMemberIdentity {
143 pub member_id: String,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub display_name: Option<String>,
146 pub role: String,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub provider_id: Option<String>,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub model_id: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub model_name: Option<String>,
153 #[serde(skip_serializing_if = "Vec::is_empty")]
154 pub requires: Vec<String>,
155 pub route: String,
156 pub origin: String,
157 }
158
159 impl FleetMemberIdentity {
160 #[must_use]
161 pub fn from_member(member: &AgentProfile) -> Self {
162 let provider_id = trimmed_owned(member.profile.provider.as_deref())
163 .map(|value| bounded_identity_field(&value));
164 let model_id = trimmed_owned(member.profile.model.as_deref())
165 .map(|value| bounded_identity_field(&value));
166 let route = match (&provider_id, &model_id) {
167 (Some(provider), Some(model)) => format!("{provider}/{model}"),
168 (None, Some(model)) => model.clone(),
169 _ => "inherit".to_string(),
170 };
171 Self {
172 member_id: bounded_identity_field(&member.id),
173 display_name: trimmed_owned(member.display_name.as_deref())
174 .map(|value| bounded_identity_field(&value)),
175 role: bounded_identity_field(&public_role_label(member_role(member))),
176 provider_id,
177 model_name: friendly_model_name(member).map(|value| bounded_identity_field(&value)),
178 model_id,
179 requires: member
180 .requires
181 .iter()
182 .take(MemberCapability::VOCABULARY.len())
183 .map(|value| bounded_identity_field(value))
184 .collect(),
185 route,
186 origin: member.origin.to_string(),
187 }
188 }
189 }
190
191 pub(crate) fn bounded_identity_field(value: &str) -> String {
192 bounded_visible_text(value, MAX_IDENTITY_FIELD_CHARS)
193 }
194
195 fn bounded_fleet_label(value: &str) -> String {
196 crate::safe_label::SafeLabel::phrase(value).to_string()
197 }
198
199 fn bounded_visible_text(value: &str, max_chars: usize) -> String {
200 let mut output = String::new();
201 let mut pending_space = false;
202 let mut output_chars = 0usize;
203 for ch in value.trim().chars() {
204 if ch.is_control() || ch.is_whitespace() {
205 pending_space = !output.is_empty();
206 continue;
207 }
208 if pending_space && output_chars < max_chars {
209 output.push(' ');
210 output_chars += 1;
211 }
212 pending_space = false;
213 if output_chars >= max_chars {
214 break;
215 }
216 output.push(ch);
217 output_chars += 1;
218 }
219 output
220 }
221
222 fn trimmed_owned(value: Option<&str>) -> Option<String> {
223 value
224 .map(str::trim)
225 .filter(|value| !value.is_empty())
226 .map(str::to_string)
227 }
228
229 fn member_role(member: &AgentProfile) -> &str {
230 let role = member.profile.role.name.trim();
231 if role.is_empty() {
232 member.id.trim()
233 } else {
234 role
235 }
236 }
237
238 /// Offline human label for a member's explicit model pin.
239 #[must_use]
240 pub fn friendly_model_name(member: &AgentProfile) -> Option<String> {
241 let model = member.profile.model.as_deref()?.trim();
242 if model.is_empty() {
243 return None;
244 }
245 let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
246 if let Some(provider) = member
247 .profile
248 .provider
249 .as_deref()
250 .map(str::trim)
251 .filter(|provider| !provider.is_empty())
252 && let Some(offering) = catalog.provider_model(provider, model)
253 {
254 if let Some(name) = trimmed_owned(offering.name.as_deref()) {
255 return Some(name);
256 }
257 if let Some(base_model) = offering.base_model.as_deref()
258 && let Some(name) = catalog
259 .model(base_model)
260 .and_then(|model| trimmed_owned(model.name.as_deref()))
261 {
262 return Some(name);
263 }
264 }
265 catalog
266 .model(model)
267 .and_then(|model| trimmed_owned(model.name.as_deref()))
268 }
269
270 #[derive(Debug, Clone, PartialEq, Eq, Error)]
271 pub enum FleetSelectorError {
272 #[error("Fleet member selector cannot be blank")]
273 Blank,
274 #[error(
275 "Saved {origin} agent profile `{profile}` is invalid or unreadable: {path}. Fix the profile using docs/SUBAGENTS.md and retry; its route was not replaced with a default."
276 )]
277 Unavailable {
278 profile: String,
279 origin: String,
280 path: String,
281 },
282 #[error(
283 "Fleet member selector `{selector}` is ambiguous; choose one member explicitly by passing `profile` as one of: {candidates}"
284 )]
285 Ambiguous {
286 selector: String,
287 candidates: String,
288 },
289 }
290
291 /// Resolve a member selector deterministically against an already-loaded
292 /// profile slice.
293 ///
294 /// Unqualified exact ids win for compatibility. Every other identity class is
295 /// resolved as a set and succeeds only when it names one distinct member.
296 /// Fleet task dispatch uses this entry point.
297 pub fn resolve_member_in_profiles<'a>(
298 profiles: &'a [AgentProfile],
299 selector: &str,
300 ) -> Result<Option<&'a AgentProfile>, FleetSelectorError> {
301 let selector = selector.trim();
302 if selector.is_empty() {
303 return Err(FleetSelectorError::Blank);
304 }
305 let (kind, value) = selector
306 .split_once(':')
307 .filter(|(kind, value)| is_selector_kind(kind) && !value.trim().is_empty())
308 .map_or((None, selector), |(kind, value)| {
309 (Some(kind.to_ascii_lowercase()), value.trim())
310 });
311
312 if kind
313 .as_deref()
314 .is_none_or(|kind| kind == "member" || kind == "id")
315 && let Some(member) = profiles
316 .iter()
317 .find(|member| member.id.eq_ignore_ascii_case(value))
318 {
319 return Ok(Some(member));
320 }
321
322 if kind
323 .as_deref()
324 .is_none_or(|kind| kind == "member" || kind == "id")
325 && let Some(member) = profiles.iter().find(|member| {
326 public_role_label(&member.id).eq_ignore_ascii_case(&public_role_label(value))
327 })
328 {
329 return Ok(Some(member));
330 }
331
332 // A saved raw ID or qualified route outranks a coincident friendly label.
333 // Friendly member/model names remain case-insensitive only as a fallback.
334 let exact_match = |member: &AgentProfile| match kind.as_deref() {
335 Some("model") => matches_model(member, value),
336 Some("route") => matches_route(member, value),
337 None => matches_model(member, value) || matches_route(member, value),
338 _ => false,
339 };
340 let prefer_exact = profiles.iter().any(exact_match);
341 let mut candidates = Vec::new();
342 for member in profiles {
343 let matches = if prefer_exact {
344 exact_match(member)
345 } else {
346 match kind.as_deref() {
347 Some("member" | "id") => false,
348 Some("name") => matches_display_name(member, value),
349 Some("role") => public_role_label(member_role(member))
350 .eq_ignore_ascii_case(&public_role_label(value)),
351 Some("model") => friendly_model_name(member)
352 .as_deref()
353 .is_some_and(|name| name.eq_ignore_ascii_case(value)),
354 Some("route") => matches_route(member, value),
355 Some(_) => false,
356 None => {
357 matches_display_name(member, value)
358 || public_role_label(member_role(member))
359 .eq_ignore_ascii_case(&public_role_label(value))
360 || matches_model(member, value)
361 || matches_route(member, value)
362 || friendly_model_name(member)
363 .as_deref()
364 .is_some_and(|name| name.eq_ignore_ascii_case(value))
365 }
366 }
367 };
368 if matches {
369 push_unique(&mut candidates, member);
370 }
371 }
372
373 if candidates.is_empty() && kind.is_none() {
374 let alias = match value.to_ascii_lowercase().as_str() {
375 "implementer" | "implement" | "implementation" => Some("builder"),
376 "release_lead" | "release-lead" | "releaselead" => Some("manager"),
377 "explore" | "explorer" | "exploration" => Some("scout"),
378 "general" | "default" => Some("worker"),
379 _ => None,
380 };
381 if let Some(member) = alias.and_then(|alias| {
382 profiles
383 .iter()
384 .find(|member| member.id.eq_ignore_ascii_case(alias))
385 }) {
386 candidates.push(member);
387 }
388 }
389
390 match candidates.as_slice() {
391 [] => Ok(None),
392 [member] => Ok(Some(*member)),
393 _ => Err(FleetSelectorError::Ambiguous {
394 selector: selector.to_string(),
395 candidates: candidates
396 .iter()
397 .take(8)
398 .map(|member| FleetMemberIdentity::from_member(member))
399 .map(|member| {
400 format!(
401 "{} (role {}, route {})",
402 member.member_id, member.role, member.route
403 )
404 })
405 .collect::<Vec<_>>()
406 .join(", "),
407 }),
408 }
409 }
410
411 fn is_selector_kind(value: &str) -> bool {
412 matches!(
413 value.trim().to_ascii_lowercase().as_str(),
414 "member" | "id" | "name" | "role" | "model" | "route"
415 )
416 }
417
418 fn matches_display_name(member: &AgentProfile, value: &str) -> bool {
419 member
420 .display_name
421 .as_deref()
422 .map(str::trim)
423 .filter(|name| !name.is_empty())
424 .is_some_and(|name| name.eq_ignore_ascii_case(value))
425 }
426
427 fn matches_model(member: &AgentProfile, value: &str) -> bool {
428 member
429 .profile
430 .model
431 .as_deref()
432 .map(str::trim)
433 .filter(|model| !model.is_empty())
434 .is_some_and(|model| model == value)
435 }
436
437 fn matches_route(member: &AgentProfile, value: &str) -> bool {
438 let Some((provider, model)) = value.split_once('/') else {
439 return false;
440 };
441 member
442 .profile
443 .provider
444 .as_deref()
445 .map(str::trim)
446 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(provider.trim()))
447 && member
448 .profile
449 .model
450 .as_deref()
451 .map(str::trim)
452 .is_some_and(|candidate| candidate == model.trim())
453 }
454
455 fn push_unique<'a>(members: &mut Vec<&'a AgentProfile>, member: &'a AgentProfile) {
456 if !members
457 .iter()
458 .any(|existing| existing.id.eq_ignore_ascii_case(&member.id))
459 {
460 members.push(member);
461 }
462 }
463
464 #[cfg(test)]
465 mod tests {
466 use super::super::store::{FLEET_SCHEMA_KIND, FLEET_SCHEMA_REVISION, FleetMember};
467 use super::*;
468 use std::path::PathBuf;
469
470 fn member(
471 id: &str,
472 display_name: Option<&str>,
473 role: &str,
474 provider: Option<&str>,
475 model: Option<&str>,
476 ) -> AgentProfile {
477 AgentProfile {
478 id: id.to_string(),
479 display_name: display_name.map(str::to_string),
480 description: None,
481 requires: Vec::new(),
482 profile: FleetProfile {
483 slot: FleetSlot::from_name(role),
484 role: FleetRole {
485 name: role.to_string(),
486 description: None,
487 instructions: None,
488 },
489 loadout: FleetLoadout::Inherit,
490 model: model.map(str::to_string),
491 provider: provider.map(str::to_string),
492 reasoning_effort: None,
493 permissions: FleetProfilePermissions::default(),
494 delegation: FleetDelegationHints::default(),
495 },
496 source: PathBuf::from("test"),
497 origin: ProfileOrigin::Workspace,
498 plugin_authority: None,
499 }
500 }
501
502 fn stored_member(id: &str, display_name: Option<&str>, role: &str) -> FleetMember {
503 FleetMember {
504 id: id.to_string(),
505 display_name: display_name.map(str::to_string),
506 shortlist: false,
507 role: role.to_string(),
508 model: None,
509 provider: None,
510 reasoning: None,
511 instructions: None,
512 requires: Vec::new(),
513 }
514 }
515
516 #[test]
517 fn selected_v2_members_project_exact_identity_and_route() {
518 let fleet = FleetFile {
519 schema: FLEET_SCHEMA_KIND.to_string(),
520 schema_revision: FLEET_SCHEMA_REVISION,
521 name: "Launch".to_string(),
522 description: None,
523 operator: None,
524 members: vec![FleetMember {
525 id: "Scout-One".to_string(),
526 display_name: Some("Flash Scout".to_string()),
527 shortlist: false,
528 role: "scout".to_string(),
529 model: Some("deepseek-v4-flash".to_string()),
530 provider: Some("deepseek".to_string()),
531 reasoning: Some("low".to_string()),
532 instructions: Some("Inspect only.".to_string()),
533 requires: vec!["vision".to_string()],
534 }],
535 };
536 let roster = roster_from_fleet(
537 &fleet,
538 FleetScope::Workspace,
539 Path::new(".codewhale/fleets/launch.toml"),
540 );
541 let projected = roster.get("scout-one").expect("case-insensitive id");
542 assert_eq!(projected.display_name.as_deref(), Some("Flash Scout"));
543 assert_eq!(projected.profile.role.name, "explore");
544 assert_eq!(projected.profile.provider.as_deref(), Some("deepseek"));
545 assert_eq!(
546 projected.profile.model.as_deref(),
547 Some("deepseek-v4-flash")
548 );
549 assert_eq!(projected.profile.reasoning_effort.as_deref(), Some("low"));
550 assert_eq!(
551 projected.profile.role.instructions.as_deref(),
552 Some("Inspect only.")
553 );
554 assert_eq!(projected.requires, vec!["vision".to_string()]);
555 }
556
557 #[test]
558 fn selected_shortlist_excludes_model_roles_and_preserves_id_only_members_on_reload() {
559 use crate::fleet::store::{FleetOperator, save_fleet, set_selected};
560 let _lock = crate::test_support::lock_test_env();
561 let workspace = tempfile::tempdir().unwrap();
562 let _home =
563 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", workspace.path().join("home"));
564 let mut fleet = FleetFile::parse(
565 r#"schema = "fleet"
566 schema_revision = 2
567 name = "Id-only roles"
568
569 [[members]]
570 id = "general"
571 provider = "deepseek"
572 model = "deepseek-v4-pro"
573
574 [[members]]
575 id = "audit-team"
576 provider = "custom-a"
577 model = "private-review-model"
578
579 [[members]]
580 id = "model-choice"
581 shortlist = true
582 provider = "openrouter"
583 model = "qwen/qwen3.7-plus"
584 "#,
585 )
586 .unwrap();
587 save_fleet(&fleet, FleetScope::Workspace, workspace.path()).unwrap();
588 set_selected(&fleet.name, FleetScope::Workspace, workspace.path()).unwrap();
589 let roster = load_effective_roster(&Default::default(), workspace.path(), None);
590 assert!(
591 roster.is_exact_selection(),
592 "unmarked id-only members are real saved roles"
593 );
594 assert_eq!(roster.members().len(), 2);
595 assert!(roster.get("model-choice").is_none());
596 for (id, provider, model) in [
597 ("general", "deepseek", "deepseek-v4-pro"),
598 ("audit-team", "custom-a", "private-review-model"),
599 ] {
600 let member = roster.get(id).expect("id-only member preserved");
601 assert_eq!(member.profile.role.name, id);
602 assert_eq!(member.profile.provider.as_deref(), Some(provider));
603 assert_eq!(member.profile.model.as_deref(), Some(model));
604 }
605
606 fleet.members.retain(|member| member.shortlist);
607 save_fleet(&fleet, FleetScope::Workspace, workspace.path()).unwrap();
608 let roster = load_effective_roster(&Default::default(), workspace.path(), None);
609 assert!(
610 !roster.is_exact_selection(),
611 "shortlist alone preserves default roles"
612 );
613 assert!(!roster.members().is_empty());
614 assert!(
615 roster
616 .members()
617 .iter()
618 .all(|member| member.origin == ProfileOrigin::BuiltIn)
619 );
620 assert!(roster.get("model-choice").is_none());
621
622 fleet.operator = Some(FleetOperator {
623 provider: "deepseek".into(),
624 model: "deepseek-v4-flash".into(),
625 reasoning: None,
626 });
627 save_fleet(&fleet, FleetScope::Workspace, workspace.path()).unwrap();
628 let roster = load_effective_roster(&Default::default(), workspace.path(), None);
629 assert!(
630 roster.is_exact_selection(),
631 "an explicit operator keeps selected Fleet policy"
632 );
633 assert!(roster.members().is_empty());
634 }
635
636 #[test]
637 fn selected_v2_member_route_precedence_is_member_then_operator_then_session() {
638 let fleet = FleetFile {
639 schema: FLEET_SCHEMA_KIND.to_string(),
640 schema_revision: FLEET_SCHEMA_REVISION,
641 name: "Launch".to_string(),
642 description: None,
643 operator: Some(super::super::store::FleetOperator {
644 provider: "deepseek".to_string(),
645 model: "deepseek-v4-flash".to_string(),
646 reasoning: Some("medium".to_string()),
647 }),
648 members: vec![
649 FleetMember {
650 id: "inherited".to_string(),
651 display_name: None,
652 shortlist: false,
653 role: "scout".to_string(),
654 model: None,
655 provider: None,
656 reasoning: None,
657 instructions: None,
658 requires: Vec::new(),
659 },
660 FleetMember {
661 id: "pinned".to_string(),
662 display_name: None,
663 shortlist: false,
664 role: "reviewer".to_string(),
665 model: Some("gpt-5.6".to_string()),
666 provider: Some("openrouter".to_string()),
667 reasoning: Some("high".to_string()),
668 instructions: None,
669 requires: Vec::new(),
670 },
671 ],
672 };
673 let roster = roster_from_fleet(
674 &fleet,
675 FleetScope::Personal,
676 Path::new("fleets/launch.toml"),
677 );
678
679 let inherited = roster.get("inherited").expect("inherited member");
680 assert_eq!(inherited.profile.provider.as_deref(), Some("deepseek"));
681 assert_eq!(
682 inherited.profile.model.as_deref(),
683 Some("deepseek-v4-flash")
684 );
685 assert_eq!(
686 inherited.profile.reasoning_effort.as_deref(),
687 Some("medium")
688 );
689
690 let pinned = roster.get("pinned").expect("pinned member");
691 assert_eq!(pinned.profile.provider.as_deref(), Some("openrouter"));
692 assert_eq!(pinned.profile.model.as_deref(), Some("gpt-5.6"));
693 assert_eq!(pinned.profile.reasoning_effort.as_deref(), Some("high"));
694 }
695
696 #[test]
697 fn selector_accepts_id_display_role_model_name_and_route() {
698 let roster = FleetRoster::from_members(vec![member(
699 "flash-scout",
700 Some("Scout One"),
701 "scout",
702 Some("deepseek"),
703 Some("deepseek-v4-flash"),
704 )]);
705 for selector in [
706 "FLASH-SCOUT",
707 "Scout One",
708 "role:scout",
709 "deepseek-v4-flash",
710 "DeepSeek V4 Flash",
711 "route:deepseek/deepseek-v4-flash",
712 ] {
713 assert_eq!(
714 resolve_member_in_profiles(roster.members(), selector)
715 .expect("valid selector")
716 .map(|member| member.id.as_str()),
717 Some("flash-scout"),
718 "selector {selector}"
719 );
720 }
721 }
722
723 #[test]
724 fn saved_case_distinct_model_and_route_selectors_are_exact() {
725 let profiles = vec![
726 member(
727 "upper",
728 Some("Upper label"),
729 "reviewer",
730 Some("openrouter"),
731 Some("Preview-fixture"),
732 ),
733 member(
734 "lower",
735 Some("Lower label"),
736 "reviewer",
737 Some("openrouter"),
738 Some("preview-fixture"),
739 ),
740 member(
741 "label-shadow",
742 Some("preview-fixture"),
743 "scout",
744 Some("openrouter"),
745 Some("unrelated-model"),
746 ),
747 ];
748 for (model, id) in [("Preview-fixture", "upper"), ("preview-fixture", "lower")] {
749 for selector in [
750 model.to_string(),
751 format!("model:{model}"),
752 format!("route:OPENROUTER/{model}"),
753 ] {
754 assert_eq!(
755 resolve_member_in_profiles(&profiles, &selector)
756 .unwrap()
757 .unwrap()
758 .id,
759 id
760 );
761 }
762 }
763 for selector in ["model:PREVIEW-FIXTURE", "route:openrouter/PREVIEW-FIXTURE"] {
764 assert!(
765 resolve_member_in_profiles(&profiles, selector)
766 .unwrap()
767 .is_none()
768 );
769 }
770 assert_eq!(
771 resolve_member_in_profiles(&profiles, "name:UPPER LABEL")
772 .unwrap()
773 .unwrap()
774 .id,
775 "upper"
776 );
777 assert_eq!(
778 resolve_member_in_profiles(&profiles, "name:PREVIEW-FIXTURE")
779 .unwrap()
780 .unwrap()
781 .id,
782 "label-shadow"
783 );
784 assert!(resolve_member_in_profiles(&profiles, "role:reviewer").is_err());
785 }
786
787 #[test]
788 fn canonical_role_selectors_resolve_legacy_builtin_member_ids() {
789 let roster = FleetRoster::built_ins_only();
790
791 assert_eq!(
792 resolve_member_in_profiles(roster.members(), "explore")
793 .expect("canonical role selector")
794 .map(|member| member.id.as_str()),
795 Some("scout")
796 );
797 assert_eq!(
798 resolve_member_in_profiles(roster.members(), "advisor")
799 .expect("canonical role selector")
800 .map(|member| member.id.as_str()),
801 Some("consultant")
802 );
803 }
804
805 #[test]
806 fn selected_v2_friendly_name_selector_is_deterministic() {
807 let fleet = FleetFile {
808 schema: FLEET_SCHEMA_KIND.to_string(),
809 schema_revision: FLEET_SCHEMA_REVISION,
810 name: "Named members".to_string(),
811 description: None,
812 operator: None,
813 members: vec![
814 stored_member("release-lead", Some("Release Lead"), "manager"),
815 stored_member("scout-a", Some("Flash Scout"), "scout"),
816 stored_member("scout-b", Some("Flash Scout"), "reviewer"),
817 ],
818 };
819 let roster = roster_from_fleet(
820 &fleet,
821 FleetScope::Workspace,
822 Path::new(".codewhale/fleets/named-members.toml"),
823 );
824
825 for selector in ["Release Lead", "name:Release Lead"] {
826 assert_eq!(
827 resolve_member_in_profiles(roster.members(), selector)
828 .expect("unique friendly name")
829 .map(|member| member.id.as_str()),
830 Some("release-lead"),
831 "selector {selector}"
832 );
833 }
834 let error = resolve_member_in_profiles(roster.members(), "name:Flash Scout")
835 .expect_err("duplicate friendly name must be ambiguous");
836 let FleetSelectorError::Ambiguous { candidates, .. } = error else {
837 panic!("expected ambiguity");
838 };
839 assert!(candidates.contains("scout-a"), "{candidates}");
840 assert!(candidates.contains("scout-b"), "{candidates}");
841
842 assert_eq!(
843 roster.members()[0].display_name.as_deref(),
844 Some("Release Lead")
845 );
846 }
847
848 #[test]
849 fn duplicate_model_label_is_ambiguous_and_member_id_still_wins() {
850 let roster = FleetRoster::from_members(vec![
851 member(
852 "scout-a",
853 None,
854 "scout",
855 Some("deepseek"),
856 Some("deepseek-v4-flash"),
857 ),
858 member(
859 "scout-b",
860 None,
861 "reviewer",
862 Some("deepseek"),
863 Some("deepseek-v4-flash"),
864 ),
865 ]);
866 assert!(matches!(
867 resolve_member_in_profiles(roster.members(), "DeepSeek V4 Flash"),
868 Err(FleetSelectorError::Ambiguous { .. })
869 ));
870 assert_eq!(
871 resolve_member_in_profiles(roster.members(), "SCOUT-A")
872 .expect("id")
873 .map(|member| member.id.as_str()),
874 Some("scout-a")
875 );
876 }
877
878 #[test]
879 fn invalid_selected_fleet_becomes_visible_failure_without_legacy_members() {
880 let workspace = tempfile::TempDir::new().expect("workspace");
881 let fleets = workspace.path().join(".codewhale/fleets");
882 std::fs::create_dir_all(&fleets).unwrap();
883 std::fs::write(fleets.join("selected"), "Broken\n").unwrap();
884 std::fs::write(
885 fleets.join("broken.toml"),
886 "schema = \"fleet\"\nschema_revision = 2\nname = \"Broken\"\n[[members]]\nid = \"scout\"\nprovider = \"deepseek\"\n",
887 )
888 .unwrap();
889
890 let roster = load_effective_roster(&FleetConfigToml::default(), workspace.path(), None);
891 assert!(roster.members().is_empty());
892 let error = roster.load_error().expect("visible selected-Fleet error");
893 assert!(error.contains("Selected folder Fleet `Broken`"), "{error}");
894 assert!(!error.contains(&workspace.path().display().to_string()));
895 assert!(!error.contains("must pin both provider and model"));
896 assert!(!error.contains("provider ="));
897 assert!(error.chars().count() <= 200, "{error}");
898 }
899
900 #[test]
901 fn selected_fleet_error_label_redacts_opaque_or_quoted_names() {
902 let raw = "sk-live-abcdef0123456789abcdef 'quoted'\n/Users/operator/private";
903 let label = bounded_fleet_label(raw);
904 assert!(label.starts_with("sha256:"), "{label}");
905 assert!(!label.contains("sk-live"));
906 assert!(!label.contains("quoted"));
907 assert!(!label.contains("/Users"));
908 assert!(!label.contains('\n'));
909 assert!(label.chars().count() <= 32, "{label}");
910 }
911
912 #[test]
913 fn member_identity_fields_stay_bounded() {
914 let live = member("member-zero", None, "worker", None, None);
915 let identity = FleetMemberIdentity::from_member(&live);
916 assert_eq!(identity.member_id, "member-zero");
917 assert_eq!(identity.route, "inherit");
918 }
919 }
920
920 lines RUST