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