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