返回 CodeWhale
roster.rs
根目录 / crates / tui / src / fleet / roster.rs
1 //! Fleet roster — the persistent, inspectable party of named agent roles.
2 //!
3 //! The roster merges four layers into one config-backed lineup shared by
4 //! model-spawned sub-agents and fleet dispatch (#fleet-roster cutover
5 //! (v0.8.67)):
6 //!
7 //! - built-in members (the default party, always available; every canonical
8 //! dispatch posture — worker/scout/planner/reviewer/builder/verifier/
9 //! consultant/custom — is seeded here, #5285),
10 //! - `[fleet.profiles]` entries from config.toml,
11 //! - personal `$CODEWHALE_HOME/agents/*.toml` profile files,
12 //! - workspace `.codewhale/agents/*.toml` profile files.
13 //!
14 //! Precedence is Workspace > Personal > Config > Plugin > BuiltIn, merged by id. Loading never
15 //! fails the session: an unreadable workspace profile dir degrades to the
16 //! built-in + config layers with a log line.
17 //!
18 //! Two guardrails (#5098):
19 //!
20 //! - Shadowing is recorded, not silent: when a higher layer displaces a
21 //! lower-precedence file for the same id, the roster keeps a
22 //! [`ShadowedProfile`] receipt (logged at load, badged in the roster view)
23 //! so an edit in the losing layer is visibly ignored rather than dropped.
24 //! - Project-scope profiles (`.codewhale/agents/*.toml`) join the roster only
25 //! when project-level config is trusted for the launch; `--no-project-config`
26 //! opts the whole layer out, same as `.codewhale/config.toml` (#485).
27
28 #![allow(dead_code)]
29
30 use std::collections::HashMap;
31 use std::path::{Path, PathBuf};
32
33 use serde::{Deserialize, Serialize};
34
35 use codewhale_config::{
36 FleetConfigToml, FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions,
37 FleetRole, FleetSlot,
38 };
39
40 use super::profile::{
41 AgentProfile, AgentProfileLoadIssue, load_agent_profiles_from_dir_tolerant,
42 load_plugin_agent_profiles_from_component, load_workspace_agent_profiles_tolerant,
43 personal_agent_profile_dir,
44 };
45
46 /// Which layer a roster member came from. Higher layers override lower ones
47 /// by id (Workspace > Personal > Config > Plugin > BuiltIn).
48 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49 #[serde(rename_all = "snake_case")]
50 pub enum ProfileOrigin {
51 BuiltIn,
52 Plugin,
53 Config,
54 Personal,
55 Workspace,
56 }
57
58 impl std::fmt::Display for ProfileOrigin {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.write_str(match self {
61 Self::BuiltIn => "built-in",
62 Self::Plugin => "plugin",
63 Self::Config => "config",
64 Self::Personal => "personal",
65 Self::Workspace => "project",
66 })
67 }
68 }
69
70 /// The merged fleet roster. Think RPG saved party / K8s runconfig: a stable,
71 /// named lineup of agent roles the session can inspect and dispatch against.
72 #[derive(Debug, Clone)]
73 pub struct FleetRoster {
74 members: Vec<AgentProfile>,
75 /// True when `members` is the exact roster from one explicitly selected
76 /// v2 Fleet rather than the compatibility merge of built-ins and profile
77 /// layers. Exact rosters require every dispatched task to resolve one
78 /// deterministic member.
79 exact_selection: bool,
80 /// Lower-precedence profiles displaced by a higher layer for the same id
81 /// (#5098). Shadowing is normal precedence, but it must be VISIBLE: a
82 /// personal edit that loses to a stale project copy otherwise changes
83 /// nothing anywhere with no signal why.
84 shadowed: Vec<ShadowedProfile>,
85 /// An explicitly selected v2 Fleet could not be loaded. Consumers retain
86 /// this error instead of silently substituting the legacy roster.
87 load_error: Option<String>,
88 profile_load_issues: Vec<AgentProfileLoadIssue>,
89 }
90
91 /// A lower-precedence profile displaced by a higher layer for the same id.
92 #[derive(Debug, Clone, PartialEq, Eq)]
93 pub struct ShadowedProfile {
94 pub id: String,
95 pub shadowed_origin: ProfileOrigin,
96 pub shadowed_source: PathBuf,
97 pub winner_origin: ProfileOrigin,
98 pub winner_source: PathBuf,
99 }
100
101 /// One observed definition of a profile id, including whether it won the merge.
102 ///
103 /// Built from the winning member plus every [`ShadowedProfile`] for that id so
104 /// the roster view, detail pane, and doctor can list the full stack without
105 /// changing merge precedence (#5098 visibility).
106 #[derive(Debug, Clone, PartialEq, Eq)]
107 pub struct ProfileLayer {
108 pub origin: ProfileOrigin,
109 pub source: PathBuf,
110 pub wins: bool,
111 }
112
113 /// A profile id that exists in more than one roster layer.
114 #[derive(Debug, Clone, PartialEq, Eq)]
115 pub struct MultiLayerProfile {
116 pub id: String,
117 pub effective: ProfileOrigin,
118 pub effective_path: PathBuf,
119 pub layers: Vec<ProfileLayer>,
120 }
121
122 fn origin_precedence(origin: ProfileOrigin) -> u8 {
123 match origin {
124 ProfileOrigin::Workspace => 4,
125 ProfileOrigin::Personal => 3,
126 ProfileOrigin::Config => 2,
127 ProfileOrigin::Plugin => 1,
128 ProfileOrigin::BuiltIn => 0,
129 }
130 }
131
132 /// Process-launch decision: whether project-scope agent profiles
133 /// (`.codewhale/agents/*.toml`) may join the dispatch roster (#5098). Set
134 /// once from `--no-project-config` at launch so every roster re-read (spawn
135 /// refresh, dispatch, views) honors the same trust decision other
136 /// project-level config already has (#485). Defaults to enabled, matching
137 /// project config itself.
138 static PROJECT_AGENT_PROFILES_ENABLED: std::sync::atomic::AtomicBool =
139 std::sync::atomic::AtomicBool::new(true);
140
141 /// Record the launch-time trust decision for project-scope agent profiles.
142 pub fn set_project_agent_profiles_enabled(enabled: bool) {
143 PROJECT_AGENT_PROFILES_ENABLED.store(enabled, std::sync::atomic::Ordering::Relaxed);
144 }
145
146 /// Whether project-scope agent profiles join the roster in this process.
147 #[must_use]
148 pub fn project_agent_profiles_enabled() -> bool {
149 PROJECT_AGENT_PROFILES_ENABLED.load(std::sync::atomic::Ordering::Relaxed)
150 }
151
152 impl FleetRoster {
153 /// Roster containing only the built-in party. Used as the runtime default
154 /// before config/workspace layers are wired in.
155 #[must_use]
156 pub fn built_ins_only() -> Self {
157 Self {
158 members: Self::built_in_members(),
159 exact_selection: false,
160 shadowed: Vec::new(),
161 load_error: None,
162 profile_load_issues: Vec::new(),
163 }
164 }
165
166 /// A roster built from an explicit member list.
167 ///
168 /// Used for run-scoped rosters that are not a merge of the config layers —
169 /// notably an exact named Fleet, whose members are frozen at Workflow
170 /// start and must not pick up built-in or workspace profiles by name.
171 #[must_use]
172 pub fn from_members(members: Vec<AgentProfile>) -> Self {
173 Self {
174 members,
175 exact_selection: true,
176 shadowed: Vec::new(),
177 load_error: None,
178 profile_load_issues: Vec::new(),
179 }
180 }
181
182 /// An unusable explicitly selected Fleet. It deliberately contains no
183 /// fallback members: running a different team would hide the selection
184 /// failure.
185 #[must_use]
186 pub fn failed(error: impl Into<String>) -> Self {
187 Self {
188 members: Vec::new(),
189 exact_selection: true,
190 shadowed: Vec::new(),
191 load_error: Some(error.into()),
192 profile_load_issues: Vec::new(),
193 }
194 }
195
196 /// Load and merge the full roster for a workspace.
197 ///
198 /// Config members come from `[fleet.profiles]` (id = map key). Personal
199 /// members come from `$CODEWHALE_HOME/agents/*.toml`, and workspace members
200 /// come from `.codewhale/agents/*.toml`. A load failure is logged and
201 /// skipped so one broken profile layer cannot take down the session.
202 #[must_use]
203 pub fn load(fleet_config: &FleetConfigToml, workspace: &Path) -> Self {
204 let personal_dir = personal_agent_profile_dir().ok();
205 Self::load_with_personal_dir_and_plugins(
206 fleet_config,
207 workspace,
208 personal_dir.as_deref(),
209 project_agent_profiles_enabled(),
210 None,
211 )
212 }
213
214 /// Load the ordinary roster plus trusted, enabled plugin Agent profiles.
215 #[must_use]
216 pub fn load_with_plugins(
217 fleet_config: &FleetConfigToml,
218 workspace: &Path,
219 plugins: &crate::plugins::PluginRegistry,
220 ) -> Self {
221 let personal_dir = personal_agent_profile_dir().ok();
222 Self::load_with_personal_dir_and_plugins(
223 fleet_config,
224 workspace,
225 personal_dir.as_deref(),
226 project_agent_profiles_enabled(),
227 Some(plugins),
228 )
229 }
230
231 fn load_with_personal_dir(
232 fleet_config: &FleetConfigToml,
233 workspace: &Path,
234 personal_dir: Option<&Path>,
235 include_workspace_profiles: bool,
236 ) -> Self {
237 Self::load_with_personal_dir_and_plugins(
238 fleet_config,
239 workspace,
240 personal_dir,
241 include_workspace_profiles,
242 None,
243 )
244 }
245
246 fn load_with_personal_dir_and_plugins(
247 fleet_config: &FleetConfigToml,
248 workspace: &Path,
249 personal_dir: Option<&Path>,
250 include_workspace_profiles: bool,
251 plugins: Option<&crate::plugins::PluginRegistry>,
252 ) -> Self {
253 let mut built_ins = Self::built_in_members();
254 let mut extras: Vec<AgentProfile> = Vec::new();
255 let mut shadowed: Vec<ShadowedProfile> = Vec::new();
256 let mut profile_load_issues = Vec::new();
257
258 if let Some(plugins) = plugins {
259 let (sources, errors) = crate::plugins::runtime::active_component_sources(
260 plugins,
261 crate::plugins::activation::PluginActivationCapability::Agents,
262 );
263 for error in errors {
264 tracing::warn!("fleet roster: {error}");
265 }
266 for source in sources {
267 match load_plugin_agent_profiles_from_component(&source.path, &source.authority) {
268 Ok((profiles, issues)) => {
269 for issue in &issues {
270 tracing::warn!(
271 plugin = %source.plugin_name,
272 "fleet roster: skipping invalid plugin Agent profile: {issue}"
273 );
274 }
275 profile_load_issues.extend(issues);
276 for member in profiles {
277 record_shadow(
278 merge_member(&mut built_ins, &mut extras, member),
279 &mut shadowed,
280 );
281 }
282 }
283 Err(error) => tracing::warn!(
284 plugin = %source.plugin_name,
285 "fleet roster: failed to load plugin Agent profiles: {error:#}"
286 ),
287 }
288 }
289 }
290
291 for (id, profile) in &fleet_config.profiles {
292 let mut profile = profile.clone();
293 profile.role.name = super::profile::canonical_public_role_name(&profile.role.name);
294 profile.slot = FleetSlot::from_name(&profile.role.name);
295 let member = AgentProfile {
296 id: id.clone(),
297 display_name: None,
298 description: profile.role.description.clone(),
299 requires: Vec::new(),
300 profile,
301 source: PathBuf::from("config.toml"),
302 origin: ProfileOrigin::Config,
303 plugin_authority: None,
304 };
305 record_shadow(
306 merge_member(&mut built_ins, &mut extras, member),
307 &mut shadowed,
308 );
309 }
310
311 if let Some(personal_dir) = personal_dir {
312 match load_agent_profiles_from_dir_tolerant(personal_dir, ProfileOrigin::Personal) {
313 Ok((profiles, issues)) => {
314 for issue in &issues {
315 tracing::warn!(
316 "fleet roster: skipping invalid personal agent profile: {issue}"
317 );
318 }
319 profile_load_issues.extend(issues);
320 for member in profiles {
321 record_shadow(
322 merge_member(&mut built_ins, &mut extras, member),
323 &mut shadowed,
324 );
325 }
326 }
327 Err(err) => {
328 tracing::warn!("fleet roster: skipping personal agent profiles: {err:#}");
329 }
330 }
331 }
332
333 // #5098: project-scope profiles join the dispatch roster only when the
334 // launch trusted project-level config (`--no-project-config` opts the
335 // whole layer out, same as `.codewhale/config.toml`).
336 if include_workspace_profiles {
337 match load_workspace_agent_profiles_tolerant(workspace) {
338 Ok((profiles, issues)) => {
339 for issue in &issues {
340 tracing::warn!(
341 workspace = %workspace.display(),
342 "fleet roster: skipping invalid workspace agent profile: {issue}"
343 );
344 }
345 profile_load_issues.extend(issues);
346 for member in profiles {
347 record_shadow(
348 merge_member(&mut built_ins, &mut extras, member),
349 &mut shadowed,
350 );
351 }
352 }
353 Err(err) => {
354 tracing::warn!(
355 workspace = %workspace.display(),
356 "fleet roster: skipping workspace agent profiles: {err:#}"
357 );
358 }
359 }
360 }
361
362 for shadow in &shadowed {
363 // Overriding a built-in is the intended customization path —
364 // keep it quiet. A file layer (config/personal) losing to another
365 // file layer is the #5098 footgun: the edit changes nothing
366 // anywhere and must be visible.
367 if shadow.shadowed_origin == ProfileOrigin::BuiltIn {
368 tracing::debug!(
369 "fleet roster: '{}' {} copy at {} overrides the built-in default",
370 shadow.id,
371 shadow.winner_origin,
372 shadow.winner_source.display()
373 );
374 } else {
375 tracing::warn!(
376 "fleet roster: '{}' {} copy at {} shadows the {} copy at {} (ignored)",
377 shadow.id,
378 shadow.winner_origin,
379 shadow.winner_source.display(),
380 shadow.shadowed_origin,
381 shadow.shadowed_source.display()
382 );
383 }
384 }
385
386 // Built-ins keep their canonical slot order (overrides included);
387 // config/workspace-only extras follow alphabetically.
388 extras.sort_by_key(|a| a.id.to_lowercase());
389 let mut members = built_ins;
390 members.extend(extras);
391 Self {
392 members,
393 exact_selection: false,
394 shadowed,
395 load_error: None,
396 profile_load_issues,
397 }
398 }
399
400 /// The default party. Built-ins carry no permission grants (permissions
401 /// stay at the [`FleetProfilePermissions::default`] floor); behavior comes
402 /// from the role posture / system prompts plus the role `instructions`
403 /// below, which encode the coordination hierarchy: the **operator** (the
404 /// session's `/model` selection) directs the work and assigns managers
405 /// to workflows; a **manager** is the middle manager of one workflow.
406 #[must_use]
407 pub fn built_in_members() -> Vec<AgentProfile> {
408 [
409 (
410 "manager",
411 FleetSlot::Manager,
412 FleetLoadout::Inherit,
413 "Middle manager for one workflow: decomposes it into bounded tasks, dispatches workers, integrates results, and reports to the operator.",
414 Some(
415 "You lead exactly one workflow. Decompose it into bounded tasks, dispatch them to the right roles, keep work-in-progress small, integrate the results, and report a concise receipt (what was done, evidence, gaps) upward. Do not take on work outside your workflow.",
416 ),
417 ),
418 (
419 "operator",
420 FleetSlot::Operator,
421 FleetLoadout::Inherit,
422 "The helm of the session — the session's /model selection. Assigns managers to Workflows, routes work between them, arbitrates conflicts, and reviews what comes back.",
423 Some(
424 "You direct the overall work, not individual Workflow steps. Assign a manager per Workflow, route work and context between them, arbitrate conflicts and priorities, review the receipts that come back, and decide what runs next. Delegate execution; keep judgment.",
425 ),
426 ),
427 (
428 "scout",
429 FleetSlot::Scout,
430 FleetLoadout::Inherit,
431 "Read-only scouting: find files, map code, gather evidence.",
432 None,
433 ),
434 (
435 "builder",
436 FleetSlot::Implementer,
437 FleetLoadout::Inherit,
438 "Writes code: implements bounded tasks with write and shell access.",
439 None,
440 ),
441 (
442 "reviewer",
443 FleetSlot::Reviewer,
444 FleetLoadout::Inherit,
445 "Adversarial code review: assumes the change is broken and tries to prove it — regressions, missing tests, unhandled cases. Read-only.",
446 Some(
447 "Be adversarial: assume the change is wrong until the evidence proves otherwise. Actively try to refute the claims made about the work — hunt regressions, missing tests, unhandled edge cases, and quiet behavior changes. Report severity-scored findings with file:line evidence; if nothing survives your attack, say so plainly. Never patch.",
448 ),
449 ),
450 (
451 "verifier",
452 FleetSlot::Verifier,
453 FleetLoadout::Inherit,
454 "Runs builds and tests to verify claims; reports evidence, does not patch.",
455 None,
456 ),
457 (
458 "consultant",
459 FleetSlot::Custom("consultant".to_string()),
460 FleetLoadout::Inherit,
461 "Short-lived, high-reasoning, read-only counsel for difficult decisions and overlooked risks.",
462 Some(
463 "Give the operator a direct second opinion grounded in what you can read. Surface the decisive tradeoff, overlooked failure mode, and your recommendation. Advise only: do not edit files or run commands.",
464 ),
465 ),
466 (
467 "synthesizer",
468 FleetSlot::Summarizer,
469 FleetLoadout::Inherit,
470 "Read-only synthesis: merge findings into one coherent report.",
471 None,
472 ),
473 // The eight canonical dispatch postures are seeded roster members
474 // (#5285). Every `type`/`role` token the Agent tool accepts maps
475 // 1:1 to a named roster profile, so dispatch always resolves
476 // through the roster instead of a parallel hidden enum. `worker`,
477 // `planner`, and `custom` complete the set the roster previously
478 // could not see (scout/builder/reviewer/verifier/consultant were
479 // already seeded).
480 (
481 "worker",
482 FleetSlot::General,
483 FleetLoadout::Inherit,
484 "General-purpose worker: full tool access for multi-step tasks. The unnamed dispatch default.",
485 None,
486 ),
487 (
488 "planner",
489 FleetSlot::Planner,
490 FleetLoadout::Inherit,
491 "Planning: grounded strategy; read-only workspace, network reads, read-only shell probes.",
492 None,
493 ),
494 (
495 "custom",
496 FleetSlot::Custom("custom".to_string()),
497 FleetLoadout::Inherit,
498 "Custom tool access: inherits the parent's write/network/shell posture; narrowed by allowed_tools.",
499 None,
500 ),
501 ]
502 .into_iter()
503 .map(|(id, slot, loadout, description, instructions)| AgentProfile {
504 id: id.to_string(),
505 display_name: None,
506 description: Some(description.to_string()),
507 requires: Vec::new(),
508 profile: FleetProfile {
509 slot,
510 role: FleetRole {
511 name: id.to_string(),
512 description: Some(description.to_string()),
513 instructions: instructions.map(str::to_string),
514 },
515 loadout,
516 model: None,
517 provider: None,
518 reasoning_effort: (id == "consultant").then(|| "high".to_string()),
519 permissions: FleetProfilePermissions::default(),
520 delegation: FleetDelegationHints::default(),
521 },
522 source: PathBuf::from("built-in"),
523 origin: ProfileOrigin::BuiltIn,
524 plugin_authority: None,
525 })
526 .collect()
527 }
528
529 /// Look up a member by id (trimmed, case-insensitive).
530 #[must_use]
531 pub fn get(&self, id: &str) -> Option<&AgentProfile> {
532 let id = id.trim();
533 self.members
534 .iter()
535 .find(|member| member.id.trim().eq_ignore_ascii_case(id))
536 }
537
538 /// All members in stable order: built-in canonical order first (an
539 /// overridden built-in keeps its slot but shows its overriding origin),
540 /// then extra config/workspace-only members alphabetically.
541 #[must_use]
542 pub fn members(&self) -> &[AgentProfile] {
543 &self.members
544 }
545
546 /// Error from an explicitly selected Fleet, if loading it failed.
547 #[must_use]
548 pub fn load_error(&self) -> Option<&str> {
549 self.load_error.as_deref()
550 }
551
552 pub fn profile_load_issues(&self) -> &[AgentProfileLoadIssue] {
553 &self.profile_load_issues
554 }
555
556 /// A named broken override is not permission to use a built-in or an older
557 /// route. Other members remain usable, and a valid higher layer still wins.
558 pub fn resolve_member(
559 &self,
560 selector: &str,
561 ) -> Result<Option<&AgentProfile>, super::identity::FleetSelectorError> {
562 use super::identity::{
563 FleetSelectorError, bounded_identity_field, resolve_member_in_profiles,
564 };
565 let member = resolve_member_in_profiles(&self.members, selector)?;
566 let requested_id = selector
567 .trim()
568 .split_once(':')
569 .filter(|(kind, _)| matches!(kind.to_ascii_lowercase().as_str(), "member" | "id"))
570 .map_or(selector.trim(), |(_, id)| id.trim());
571 let issue = self
572 .profile_load_issues
573 .iter()
574 .filter(|issue| {
575 let same_id = issue.id.eq_ignore_ascii_case(requested_id)
576 || member.is_some_and(|member| {
577 super::role::public_role_label(&issue.id)
578 .eq_ignore_ascii_case(&super::role::public_role_label(&member.id))
579 });
580 same_id
581 && member.is_none_or(|member| {
582 origin_precedence(issue.origin) >= origin_precedence(member.origin)
583 })
584 })
585 .max_by_key(|issue| origin_precedence(issue.origin));
586 if let Some(issue) = issue {
587 return Err(FleetSelectorError::Unavailable {
588 profile: bounded_identity_field(&issue.id),
589 origin: issue.origin.to_string(),
590 path: bounded_identity_field(&issue.source.to_string_lossy()),
591 });
592 }
593 Ok(member)
594 }
595
596 /// Whether this roster came from one explicitly selected v2 Fleet.
597 #[must_use]
598 pub fn is_exact_selection(&self) -> bool {
599 self.exact_selection
600 }
601
602 /// Per-member explicit model pins, keyed by lowercased member id.
603 /// Feeds the sub-agent `role_models` lookup; explicit `[subagents]`
604 /// overrides are merged on top by the engine and win.
605 ///
606 /// Members that also pin a provider are deliberately excluded. Their
607 /// complete saved profiles bind provider/model together through the
608 /// profile spawn path; copying a bare model here would discard route
609 /// identity. Provider-less saved defaults share the typed override map
610 /// with explicit subagent configuration, without a second lookup table.
611 #[must_use]
612 pub fn model_overrides(&self) -> HashMap<String, crate::config::SubagentModelOverride> {
613 self.members
614 .iter()
615 .filter_map(|member| {
616 if member
617 .profile
618 .provider
619 .as_deref()
620 .is_some_and(|provider| !provider.trim().is_empty())
621 {
622 return None;
623 }
624 let model = member.profile.model.as_deref()?.trim();
625 (!model.is_empty()).then(|| (member.id.to_lowercase(), model.into()))
626 })
627 .collect()
628 }
629 /// Lower-precedence profiles displaced by higher layers (#5098). Empty
630 /// for `built_ins_only` / `from_members` rosters.
631 #[must_use]
632 pub fn shadowed(&self) -> &[ShadowedProfile] {
633 &self.shadowed
634 }
635
636 /// Shadow records for one member id (trimmed, case-insensitive).
637 pub fn shadowed_for<'a>(&'a self, id: &'a str) -> impl Iterator<Item = &'a ShadowedProfile> {
638 let id = id.trim().to_lowercase();
639 self.shadowed
640 .iter()
641 .filter(move |shadow| shadow.id.trim().eq_ignore_ascii_case(&id))
642 }
643
644 /// Every layer that defined `id`, winner first, then remaining layers
645 /// from highest remaining precedence to lowest.
646 #[must_use]
647 pub fn layers_for(&self, id: &str) -> Vec<ProfileLayer> {
648 let Some(member) = self.get(id) else {
649 return Vec::new();
650 };
651 layers_from_parts(member, &self.shadowed)
652 }
653
654 /// Profile ids defined in more than one layer (sorted), with the winning
655 /// layer and every losing path. Empty when nothing is shadowed.
656 #[must_use]
657 pub fn multi_layer_report(&self) -> Vec<MultiLayerProfile> {
658 let mut ids: Vec<String> = self
659 .members
660 .iter()
661 .filter(|member| self.layers_for(&member.id).len() > 1)
662 .map(|member| member.id.clone())
663 .collect();
664 ids.sort_by_key(|id| id.to_lowercase());
665 ids.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
666 ids.into_iter()
667 .filter_map(|id| {
668 let layers = self.layers_for(&id);
669 let winner = layers.iter().find(|layer| layer.wins)?;
670 Some(MultiLayerProfile {
671 id,
672 effective: winner.origin,
673 effective_path: winner.source.clone(),
674 layers,
675 })
676 })
677 .collect()
678 }
679
680 /// Human doctor lines for multi-layer profile ids: effective layer plus
681 /// every observed path. Empty when no id is defined in more than one layer.
682 #[must_use]
683 pub fn doctor_layer_lines(&self) -> Vec<String> {
684 let report = self.multi_layer_report();
685 if report.is_empty() {
686 return Vec::new();
687 }
688 let mut lines = Vec::new();
689 for entry in report {
690 lines.push(format!(
691 "{}: effective={} · {}",
692 entry.id,
693 entry.effective,
694 crate::utils::display_path(&entry.effective_path)
695 ));
696 for layer in &entry.layers {
697 let mark = if layer.wins { "wins" } else { "ignored" };
698 lines.push(format!(
699 " {} · {} ({mark})",
700 layer.origin,
701 crate::utils::display_path(&layer.source)
702 ));
703 }
704 }
705 lines
706 }
707 }
708
709 /// Reconstruct the full layer stack for a member from the winning copy plus
710 /// every recorded displacement. Used by the roster view (which snapshots
711 /// members + shadows) and by [`FleetRoster::layers_for`].
712 #[must_use]
713 pub fn layers_from_parts(member: &AgentProfile, shadowed: &[ShadowedProfile]) -> Vec<ProfileLayer> {
714 let mut layers = vec![ProfileLayer {
715 origin: member.origin,
716 source: member.source.clone(),
717 wins: true,
718 }];
719 for shadow in shadowed
720 .iter()
721 .filter(|shadow| shadow.id.trim().eq_ignore_ascii_case(member.id.trim()))
722 {
723 let already = layers.iter().any(|layer| {
724 layer.origin == shadow.shadowed_origin && layer.source == shadow.shadowed_source
725 });
726 if !already {
727 layers.push(ProfileLayer {
728 origin: shadow.shadowed_origin,
729 source: shadow.shadowed_source.clone(),
730 wins: false,
731 });
732 }
733 }
734 layers.sort_by(|a, b| match (a.wins, b.wins) {
735 (true, false) => std::cmp::Ordering::Less,
736 (false, true) => std::cmp::Ordering::Greater,
737 _ => origin_precedence(b.origin).cmp(&origin_precedence(a.origin)),
738 });
739 layers
740 }
741
742 /// Fold a displaced layer (if any) into the shadow log.
743 fn record_shadow(displaced: Option<ShadowedProfile>, shadowed: &mut Vec<ShadowedProfile>) {
744 if let Some(shadow) = displaced {
745 shadowed.push(shadow);
746 }
747 }
748
749 /// Overlay `member` onto the roster layers: replace an existing member with
750 /// the same id (case-insensitive) in place, otherwise collect it as an extra.
751 /// Returns a shadow record when a lower-precedence layer was displaced so the
752 /// load can log it and the roster can surface it (#5098).
753 fn merge_member(
754 built_ins: &mut [AgentProfile],
755 extras: &mut Vec<AgentProfile>,
756 member: AgentProfile,
757 ) -> Option<ShadowedProfile> {
758 let matches =
759 |existing: &AgentProfile| existing.id.trim().eq_ignore_ascii_case(member.id.trim());
760 let slot = built_ins
761 .iter_mut()
762 .find(|existing| matches(existing))
763 .or_else(|| extras.iter_mut().find(|existing| matches(existing)));
764 match slot {
765 Some(existing) => {
766 let shadow = ShadowedProfile {
767 id: existing.id.clone(),
768 shadowed_origin: existing.origin,
769 shadowed_source: existing.source.clone(),
770 winner_origin: member.origin,
771 winner_source: member.source.clone(),
772 };
773 *existing = member;
774 Some(shadow)
775 }
776 None => {
777 extras.push(member);
778 None
779 }
780 }
781 }
782
783 #[cfg(test)]
784 mod tests {
785 use super::*;
786 use std::collections::BTreeMap;
787 use tempfile::TempDir;
788
789 fn config_with_profiles(profiles: BTreeMap<String, FleetProfile>) -> FleetConfigToml {
790 FleetConfigToml {
791 profiles,
792 ..FleetConfigToml::default()
793 }
794 }
795
796 fn config_profile(role: &str, model: Option<&str>) -> FleetProfile {
797 FleetProfile {
798 slot: FleetSlot::from_name(role),
799 role: FleetRole {
800 name: role.to_string(),
801 description: Some(format!("{role} from config")),
802 instructions: None,
803 },
804 loadout: FleetLoadout::Inherit,
805 model: model.map(str::to_string),
806 provider: None,
807 reasoning_effort: None,
808 permissions: FleetProfilePermissions::default(),
809 delegation: FleetDelegationHints::default(),
810 }
811 }
812
813 fn write_workspace_profile(workspace: &Path, filename: &str, contents: &str) {
814 let dir = workspace.join(super::super::profile::WORKSPACE_AGENT_PROFILE_DIR);
815 std::fs::create_dir_all(&dir).unwrap();
816 std::fs::write(dir.join(filename), contents).unwrap();
817 }
818
819 /// Removing the built-in `general` member must not make the name stop
820 /// resolving. #5888 deliberately kept `general` dispatchable because Agent
821 /// tool type tokens, saved configs and replayed transcripts name it; that
822 /// contract still holds, now through the selector rather than through a
823 /// second member — which is what lets the duplicate go (#6244).
824 #[test]
825 fn general_still_resolves_to_the_worker_member_without_its_own_built_in() {
826 use crate::fleet::identity::resolve_member_in_profiles;
827 let members = FleetRoster::built_in_members();
828 assert!(
829 !members.iter().any(|m| m.id == "general"),
830 "this test is only meaningful while `general` has no built-in member"
831 );
832 for selector in ["general", "member:general", "role:general", "default"] {
833 let resolved = resolve_member_in_profiles(&members, selector)
834 .unwrap_or_else(|error| panic!("`{selector}` must resolve, got {error:?}"))
835 .unwrap_or_else(|| panic!("`{selector}` resolved to no member"));
836 assert_eq!(
837 resolved.id, "worker",
838 "`{selector}` must land on the worker posture"
839 );
840 }
841 }
842
843 /// Two built-ins that collapse to the same canonical role make
844 /// `role:<name>` permanently unresolvable: the selector canonicalizes both
845 /// sides, matches both members, and raises `Ambiguous` forever. `general`
846 /// and `worker` both canonicalized to `general`, which is what blocked
847 /// plain agent spawns in production (#6244).
848 #[test]
849 fn built_in_members_have_one_member_per_canonical_role() {
850 use crate::fleet::role::public_role_label;
851 let mut seen: std::collections::BTreeMap<String, String> =
852 std::collections::BTreeMap::new();
853 for member in FleetRoster::built_in_members() {
854 let role = public_role_label(&member.profile.role.name).to_string();
855 if let Some(existing) = seen.insert(role.clone(), member.id.clone()) {
856 panic!(
857 "built-ins `{existing}` and `{}` both canonicalize to role `{role}`; \
858 a `role:{role}` selector can never resolve",
859 member.id
860 );
861 }
862 }
863 }
864
865 #[test]
866 fn built_in_party_is_complete_with_floor_permissions() {
867 let members = FleetRoster::built_in_members();
868 let ids: Vec<&str> = members.iter().map(|m| m.id.as_str()).collect();
869 assert_eq!(
870 ids,
871 [
872 "manager",
873 "operator",
874 "scout",
875 "builder",
876 "reviewer",
877 "verifier",
878 "consultant",
879 "synthesizer",
880 "worker",
881 "planner",
882 "custom"
883 ]
884 );
885 for member in &members {
886 assert_eq!(member.origin, ProfileOrigin::BuiltIn, "{}", member.id);
887 assert_eq!(
888 member.profile.permissions,
889 FleetProfilePermissions::default(),
890 "built-in {} must stay at the permission floor",
891 member.id
892 );
893 assert_eq!(
894 member.profile.delegation,
895 FleetDelegationHints::default(),
896 "{}",
897 member.id
898 );
899 assert!(member.profile.model.is_none(), "{}", member.id);
900 assert_eq!(
901 member.profile.reasoning_effort.as_deref(),
902 (member.id == "consultant").then_some("high"),
903 "built-in {} reasoning",
904 member.id
905 );
906 // The coordination hierarchy (operator/manager) and the
907 // adversarial reviewer carry role doctrine; the remaining
908 // built-ins get behavior from posture / system prompts alone.
909 let carries_doctrine = matches!(
910 member.id.as_str(),
911 "manager" | "operator" | "reviewer" | "consultant"
912 );
913 assert_eq!(
914 member.profile.role.instructions.is_some(),
915 carries_doctrine,
916 "built-in {} instructions presence",
917 member.id
918 );
919 assert!(member.description.is_some(), "{}", member.id);
920 }
921 assert_eq!(members[0].profile.slot, FleetSlot::Manager);
922 assert_eq!(members[1].profile.slot, FleetSlot::Operator);
923 assert_eq!(members[2].profile.loadout, FleetLoadout::Inherit);
924 assert_eq!(members[6].profile.slot.as_str(), "consultant");
925 assert_eq!(members[7].profile.slot, FleetSlot::Summarizer);
926 assert_eq!(members[7].profile.loadout, FleetLoadout::Inherit);
927 }
928
929 /// #5285: there is no dispatch posture the roster cannot see. Every
930 /// canonical `type` value the Agent tool accepts resolves to a seeded
931 /// roster member, so sub-agent dispatch always has a profile to resolve
932 /// through (posture, route, overlay, delegation from one place).
933 #[test]
934 fn every_canonical_dispatch_posture_is_a_seeded_roster_member() {
935 let roster = FleetRoster::built_ins_only();
936 for (posture, expected_slot) in [
937 ("worker", FleetSlot::General),
938 ("scout", FleetSlot::Scout),
939 ("planner", FleetSlot::Planner),
940 ("reviewer", FleetSlot::Reviewer),
941 ("builder", FleetSlot::Implementer),
942 ("verifier", FleetSlot::Verifier),
943 ("consultant", FleetSlot::Custom("consultant".to_string())),
944 ("custom", FleetSlot::Custom("custom".to_string())),
945 ] {
946 let member = roster.get(posture).unwrap_or_else(|| {
947 panic!("dispatch posture {posture:?} must be a seeded roster member")
948 });
949 assert_eq!(
950 member.profile.slot, expected_slot,
951 "seeded posture {posture:?} slot"
952 );
953 assert_eq!(member.origin, ProfileOrigin::BuiltIn, "{posture}");
954 // Seeded postures must not carry a pinned route: they inherit the
955 // session route exactly like the unnamed default so legacy
956 // type-only dispatches keep their model route (#5285).
957 assert!(member.profile.model.is_none(), "{posture}");
958 assert!(member.profile.provider.is_none(), "{posture}");
959 assert_eq!(member.profile.loadout, FleetLoadout::Inherit, "{posture}");
960 }
961 }
962
963 #[test]
964 fn config_member_overrides_built_in_and_extras_sort_alphabetically() {
965 let _env_lock = crate::test_support::lock_test_env();
966 let home = TempDir::new().unwrap();
967 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
968 let tmp = TempDir::new().unwrap();
969 let config = config_with_profiles(BTreeMap::from([
970 (
971 "reviewer".to_string(),
972 config_profile("reviewer", Some("deepseek-v4-pro")),
973 ),
974 ("zeta".to_string(), config_profile("scout", None)),
975 ("alpha".to_string(), config_profile("builder", None)),
976 ]));
977
978 // Isolate from ambient personal agent profiles on developer machines.
979 let roster = FleetRoster::load_with_personal_dir(&config, tmp.path(), None, true);
980
981 let ids: Vec<&str> = roster.members().iter().map(|m| m.id.as_str()).collect();
982 assert_eq!(
983 ids,
984 [
985 "manager",
986 "operator",
987 "scout",
988 "builder",
989 "reviewer",
990 "verifier",
991 "consultant",
992 "synthesizer",
993 "worker",
994 "planner",
995 "custom",
996 "alpha",
997 "zeta"
998 ],
999 "overridden built-in keeps its slot; extras follow alphabetically"
1000 );
1001 let reviewer = roster.get("reviewer").unwrap();
1002 assert_eq!(reviewer.origin, ProfileOrigin::Config);
1003 assert_eq!(reviewer.profile.model.as_deref(), Some("deepseek-v4-pro"));
1004 assert_eq!(reviewer.source, PathBuf::from("config.toml"));
1005 }
1006
1007 #[test]
1008 fn workspace_member_wins_over_config_and_built_in() {
1009 let tmp = TempDir::new().unwrap();
1010 write_workspace_profile(
1011 tmp.path(),
1012 "reviewer.toml",
1013 "id = \"reviewer\"\nrole_hint = \"reviewer\"\nmodel = \"glm-5.2\"\n",
1014 );
1015 let config = config_with_profiles(BTreeMap::from([(
1016 "reviewer".to_string(),
1017 config_profile("reviewer", Some("deepseek-v4-pro")),
1018 )]));
1019
1020 let roster = FleetRoster::load(&config, tmp.path());
1021
1022 let reviewer = roster.get("reviewer").unwrap();
1023 assert_eq!(reviewer.origin, ProfileOrigin::Workspace);
1024 assert_eq!(reviewer.profile.model.as_deref(), Some("glm-5.2"));
1025 // Precedence must not duplicate the member.
1026 assert_eq!(
1027 roster
1028 .members()
1029 .iter()
1030 .filter(|m| m.id == "reviewer")
1031 .count(),
1032 1
1033 );
1034 }
1035
1036 #[test]
1037 fn personal_member_applies_across_projects_but_project_still_wins() {
1038 let tmp = TempDir::new().unwrap();
1039 let personal_dir = tmp.path().join("personal-agents");
1040 std::fs::create_dir_all(&personal_dir).unwrap();
1041 std::fs::write(
1042 personal_dir.join("reviewer.toml"),
1043 "id = \"reviewer\"\nrole_hint = \"reviewer\"\nmodel = \"deepseek-v4-flash\"\n",
1044 )
1045 .unwrap();
1046 let workspace = tmp.path().join("workspace");
1047 std::fs::create_dir_all(&workspace).unwrap();
1048
1049 let personal = FleetRoster::load_with_personal_dir(
1050 &FleetConfigToml::default(),
1051 &workspace,
1052 Some(&personal_dir),
1053 true,
1054 );
1055 let reviewer = personal.get("reviewer").unwrap();
1056 assert_eq!(reviewer.origin, ProfileOrigin::Personal);
1057 assert_eq!(reviewer.profile.model.as_deref(), Some("deepseek-v4-flash"));
1058
1059 write_workspace_profile(
1060 &workspace,
1061 "reviewer.toml",
1062 "id = \"reviewer\"\nrole_hint = \"reviewer\"\nmodel = \"glm-5.2\"\n",
1063 );
1064 let project = FleetRoster::load_with_personal_dir(
1065 &FleetConfigToml::default(),
1066 &workspace,
1067 Some(&personal_dir),
1068 true,
1069 );
1070 let reviewer = project.get("reviewer").unwrap();
1071 assert_eq!(reviewer.origin, ProfileOrigin::Workspace);
1072 assert_eq!(reviewer.profile.model.as_deref(), Some("glm-5.2"));
1073 }
1074
1075 #[test]
1076 fn personal_setup_target_round_trips_through_the_runtime_roster() {
1077 let _env_lock = crate::test_support::lock_test_env();
1078 let home = TempDir::new().unwrap();
1079 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1080 let workspace = TempDir::new().unwrap();
1081 let personal_dir = super::super::profile::agent_profile_dir_for_scope(
1082 super::super::profile::FleetProfileScope::Personal,
1083 workspace.path(),
1084 )
1085 .expect("personal profile directory");
1086 assert_eq!(personal_dir, home.path().join("agents"));
1087
1088 let target = personal_dir.join("reviewer.toml");
1089 let mut transaction = codewhale_config::persistence::SetupTransaction::new();
1090 transaction.stage(
1091 target.clone(),
1092 b"id = \"reviewer\"\nrole_hint = \"reviewer\"\nprovider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n"
1093 .to_vec(),
1094 );
1095 transaction.commit().expect("atomic personal save");
1096 assert!(target.is_file(), "save must land under CODEWHALE_HOME");
1097
1098 let roster = FleetRoster::load(&FleetConfigToml::default(), workspace.path());
1099 let reviewer = roster
1100 .get("reviewer")
1101 .expect("saved personal profile must be loaded");
1102 assert_eq!(reviewer.origin, ProfileOrigin::Personal);
1103 assert_eq!(reviewer.source, target);
1104 assert_eq!(reviewer.profile.provider.as_deref(), Some("deepseek"));
1105 assert_eq!(reviewer.profile.model.as_deref(), Some("deepseek-v4-flash"));
1106 }
1107
1108 #[test]
1109 fn broken_workspace_dir_degrades_to_built_ins_and_config() {
1110 // `load` reads the real personal agent dir under CODEWHALE_HOME; a
1111 // developer's own extra profiles must not change this assertion.
1112 let _env_lock = crate::test_support::lock_test_env();
1113 let isolated_home = TempDir::new().unwrap();
1114 let _codewhale_home =
1115 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", isolated_home.path());
1116 let tmp = TempDir::new().unwrap();
1117 // A malformed provider token is still a load failure (#4093 / #3965):
1118 // profile pins may name built-ins or simple custom ids like
1119 // `lm-studio`, but whitespace/punctuation is rejected so a broken
1120 // workspace dir still degrades to built-ins + config.
1121 write_workspace_profile(
1122 tmp.path(),
1123 "broken.toml",
1124 "provider = \"not a real provider\"\n",
1125 );
1126 let config = config_with_profiles(BTreeMap::from([(
1127 "extra".to_string(),
1128 config_profile("scout", None),
1129 )]));
1130
1131 let roster = FleetRoster::load(&config, tmp.path());
1132
1133 assert!(roster.get("extra").is_some());
1134 assert_eq!(
1135 roster.members().len(),
1136 FleetRoster::built_in_members().len() + 1
1137 );
1138 }
1139
1140 #[test]
1141 fn invalid_legacy_profile_does_not_hide_valid_scout_neighbor() {
1142 let _env_lock = crate::test_support::lock_test_env();
1143 let home = TempDir::new().unwrap();
1144 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1145 let tmp = TempDir::new().unwrap();
1146 write_workspace_profile(
1147 tmp.path(),
1148 "reviewer.toml",
1149 "id = \"reviewer\"\nmodel_class_hint = \"heavy\"\n",
1150 );
1151 write_workspace_profile(
1152 tmp.path(),
1153 "scout.toml",
1154 "id = \"scout\"\nrole_hint = \"scout\"\nprovider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n",
1155 );
1156
1157 // Isolate from ambient personal agent profiles on developer machines.
1158 let roster = FleetRoster::load_with_personal_dir(
1159 &FleetConfigToml::default(),
1160 tmp.path(),
1161 None,
1162 true,
1163 );
1164
1165 let scout = roster.get("scout").expect("valid scout remains visible");
1166 assert_eq!(scout.origin, ProfileOrigin::Workspace);
1167 assert_eq!(scout.profile.provider.as_deref(), Some("deepseek"));
1168 assert_eq!(scout.profile.model.as_deref(), Some("deepseek-v4-flash"));
1169 assert_eq!(
1170 roster.get("reviewer").unwrap().origin,
1171 ProfileOrigin::BuiltIn,
1172 "invalid legacy override must fall back to the safe built-in"
1173 );
1174 }
1175
1176 #[test]
1177 fn invalid_profile_selection_rejects_fallback_and_respects_layer_precedence() {
1178 let root = TempDir::new().unwrap();
1179 let personal = root.path().join("personal");
1180 let workspace = root.path().join("workspace");
1181 std::fs::create_dir_all(&personal).unwrap();
1182 std::fs::create_dir_all(&workspace).unwrap();
1183 std::fs::write(
1184 personal.join("scout.toml"),
1185 "allow_shell = false\ntrust = false\n",
1186 )
1187 .unwrap();
1188 let load = || {
1189 FleetRoster::load_with_personal_dir(
1190 &FleetConfigToml::default(),
1191 &workspace,
1192 Some(&personal),
1193 true,
1194 )
1195 };
1196 let roster = load();
1197 for selector in [
1198 "scout",
1199 "SCOUT",
1200 "member:scout",
1201 "id:scout",
1202 "explore",
1203 "role:explore",
1204 ] {
1205 let error = roster.resolve_member(selector).unwrap_err().to_string();
1206 assert!(
1207 error.contains("invalid or unreadable"),
1208 "{selector}: {error}"
1209 );
1210 assert!(error.contains("scout.toml"), "{error}");
1211 }
1212 assert!(roster.resolve_member("reviewer").unwrap().is_some());
1213 assert!(roster.resolve_member("missing").unwrap().is_none());
1214 write_workspace_profile(&workspace, "scout.toml", "model = \"deepseek-v4-pro\"\n");
1215 let roster = load();
1216 let winner = roster.resolve_member("scout").unwrap().unwrap();
1217 assert_eq!(winner.origin, ProfileOrigin::Workspace);
1218 assert_eq!(winner.profile.model.as_deref(), Some("deepseek-v4-pro"));
1219 std::fs::write(
1220 workspace.join(".codewhale/agents/scout.toml"),
1221 "broken = [\n",
1222 )
1223 .unwrap();
1224 assert!(load().resolve_member("scout").is_err());
1225 }
1226
1227 #[test]
1228 fn duplicate_and_renamed_invalid_profiles_keep_identity_without_parser_source() {
1229 let root = TempDir::new().unwrap();
1230 let dir = root.path().join("personal");
1231 std::fs::create_dir_all(&dir).unwrap();
1232 for file in ["first.toml", "second.toml"] {
1233 std::fs::write(dir.join(file), "id = \"reviewer\"\n").unwrap();
1234 }
1235 std::fs::write(
1236 dir.join("custom.toml"),
1237 "id = \"renamed\"\nsecret = \"FIXTURE_SECRET_MUST_STAY_IN_LOG\"\n",
1238 )
1239 .unwrap();
1240 let roster = FleetRoster::load_with_personal_dir(
1241 &FleetConfigToml::default(),
1242 root.path(),
1243 Some(&dir),
1244 false,
1245 );
1246 assert!(roster.resolve_member("reviewer").is_err());
1247 assert!(
1248 roster
1249 .resolve_member("renamed")
1250 .unwrap_err()
1251 .to_string()
1252 .contains("custom.toml")
1253 );
1254 assert_eq!(roster.profile_load_issues().len(), 3);
1255 let serialized = serde_json::to_string(roster.profile_load_issues()).unwrap();
1256 assert!(!serialized.contains("FIXTURE_SECRET_MUST_STAY_IN_LOG"));
1257 assert!(!serialized.contains("detail"));
1258 assert!(roster.resolve_member("scout").unwrap().is_some());
1259 }
1260
1261 #[test]
1262 fn model_overrides_use_lowercased_ids_and_only_explicit_models() {
1263 let _env_lock = crate::test_support::lock_test_env();
1264 let home = TempDir::new().unwrap();
1265 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1266 // Isolate personal `$CODEWHALE_HOME/agents` so ambient developer
1267 // profiles cannot pin built-ins like manager during unit tests.
1268 let tmp = TempDir::new().unwrap();
1269 let config = config_with_profiles(BTreeMap::from([
1270 (
1271 "Reviewer".to_string(),
1272 config_profile("reviewer", Some("deepseek-v4-pro")),
1273 ),
1274 ("scout".to_string(), config_profile("scout", None)),
1275 ]));
1276
1277 let roster = FleetRoster::load(&config, tmp.path());
1278 let overrides = roster.model_overrides();
1279
1280 assert_eq!(
1281 overrides,
1282 HashMap::from([(
1283 "reviewer".to_string(),
1284 crate::config::SubagentModelOverride {
1285 provider: None,
1286 model: "deepseek-v4-pro".to_string(),
1287 }
1288 )]),
1289 "only members with explicit models are pinned, keyed lowercased"
1290 );
1291 }
1292
1293 /// A profile that pins BOTH a provider and a model must not contribute to
1294 /// the provider-less `role_models` map. That map is applied against the
1295 /// session provider's client, so exporting `deepseek-v4-flash` from a
1296 /// `provider = "deepseek"` scout profile sent a bare DeepSeek id onto an
1297 /// Alibaba Model Studio session (a pass-through provider, so nothing
1298 /// downstream rejected it) and the scout died on the provider's denial —
1299 /// the Model Studio Token Plan roster serves `deepseek-v4-flash-0731`, not
1300 /// `deepseek-v4-flash`. Provider-pinned profiles keep their model via the
1301 /// profile spawn path, which builds a client for the pinned provider.
1302 #[test]
1303 fn model_overrides_skip_provider_pinned_profiles() {
1304 let _env_lock = crate::test_support::lock_test_env();
1305 let home = TempDir::new().unwrap();
1306 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1307 let tmp = TempDir::new().unwrap();
1308
1309 let mut pinned = config_profile("scout", Some("deepseek-v4-flash"));
1310 pinned.provider = Some("deepseek".to_string());
1311 let mut blank_provider = config_profile("builder", Some("deepseek-v4-pro"));
1312 blank_provider.provider = Some(" ".to_string());
1313
1314 let config = config_with_profiles(BTreeMap::from([
1315 ("scout".to_string(), pinned),
1316 ("builder".to_string(), blank_provider),
1317 ]));
1318
1319 let roster = FleetRoster::load(&config, tmp.path());
1320 let overrides = roster.model_overrides();
1321
1322 assert!(
1323 !overrides.contains_key("scout"),
1324 "a provider-pinned profile must not leak its model into the \
1325 provider-less role_models map: {overrides:?}"
1326 );
1327 assert_eq!(
1328 overrides.get("builder"),
1329 Some(&crate::config::SubagentModelOverride {
1330 provider: None,
1331 model: "deepseek-v4-pro".to_string(),
1332 }),
1333 "a blank provider pin is still provider-less: {overrides:?}"
1334 );
1335 // The pin itself survives on the member for the profile spawn path.
1336 let scout = roster.get("scout").expect("scout member");
1337 assert_eq!(scout.profile.provider.as_deref(), Some("deepseek"));
1338 assert_eq!(scout.profile.model.as_deref(), Some("deepseek-v4-flash"));
1339 }
1340
1341 #[test]
1342 fn get_is_trimmed_and_case_insensitive() {
1343 let roster = FleetRoster::built_ins_only();
1344 assert!(roster.get(" Reviewer ").is_some());
1345 assert!(roster.get("SYNTHESIZER").is_some());
1346 assert!(roster.get("nonexistent").is_none());
1347 }
1348
1349 #[test]
1350 fn origin_labels_are_stable() {
1351 assert_eq!(ProfileOrigin::BuiltIn.to_string(), "built-in");
1352 assert_eq!(ProfileOrigin::Config.to_string(), "config");
1353 assert_eq!(ProfileOrigin::Personal.to_string(), "personal");
1354 assert_eq!(ProfileOrigin::Workspace.to_string(), "project");
1355 }
1356 }
1357
1358 #[cfg(test)]
1359 #[path = "tests/roster_shadow_and_trust.rs"]
1360 mod shadow_and_trust_tests;
1361
1361 lines RUST