Skip to main content

batty_cli/team/
hierarchy.rs

1//! Instance naming and manager↔engineer partitioning.
2//!
3//! With `instances: N`, the daemon creates named instances:
4//! - `architect-1` (just 1)
5//! - `manager-1`, `manager-2`, `manager-3`
6//! - Engineers partitioned across compatible managers: `eng-1-1..eng-1-5`
7//!   (under manager-1), etc.
8
9use anyhow::{Result, bail};
10
11use super::config::{RoleDef, RoleType, TeamConfig};
12
13/// A resolved team member instance with its name, role, and hierarchy position.
14#[derive(Debug, Clone)]
15pub struct MemberInstance {
16    /// Unique instance name (e.g., "architect-1", "manager-2", "eng-1-3").
17    pub name: String,
18    /// The role definition name from team.yaml.
19    pub role_name: String,
20    /// The role type.
21    pub role_type: RoleType,
22    /// Agent to use (None for user roles).
23    pub agent: Option<String>,
24    /// Optional model hint used for prompt composition.
25    pub model: Option<String>,
26    /// Prompt template filename (relative to team_config dir).
27    pub prompt: Option<String>,
28    /// Optional prompt posture overlay.
29    pub posture: Option<String>,
30    /// Optional model capability class override.
31    pub model_class: Option<String>,
32    /// Optional provider-specific overlay.
33    pub provider_overlay: Option<String>,
34    /// Instance name this member reports to (None for top-level/user roles).
35    pub reports_to: Option<String>,
36    /// Whether this member uses git worktrees.
37    pub use_worktrees: bool,
38}
39
40impl Default for MemberInstance {
41    fn default() -> Self {
42        Self {
43            name: String::new(),
44            role_name: String::new(),
45            role_type: RoleType::Engineer,
46            agent: None,
47            model: None,
48            prompt: None,
49            posture: None,
50            model_class: None,
51            provider_overlay: None,
52            reports_to: None,
53            use_worktrees: false,
54        }
55    }
56}
57
58/// Resolve the team hierarchy into a flat list of member instances.
59///
60/// Engineer instances are multiplicative across compatible managers: each
61/// compatible manager gets `engineer.instances` engineers assigned to it.
62///
63/// Compatibility rule:
64/// - if an engineer role's `talks_to` lists specific manager role names, only
65///   those manager instances receive engineers from that role
66/// - otherwise, the engineer role is assigned across all managers
67pub fn resolve_hierarchy(config: &TeamConfig) -> Result<Vec<MemberInstance>> {
68    let mut members = Vec::new();
69
70    // Collect role defs by type for hierarchy resolution
71    let managers: Vec<_> = config
72        .roles
73        .iter()
74        .filter(|r| r.role_type == RoleType::Manager)
75        .collect();
76    let engineers: Vec<_> = config
77        .roles
78        .iter()
79        .filter(|r| r.role_type == RoleType::Engineer)
80        .collect();
81
82    // Phase 1: Add user roles (no pane, no instances beyond routing)
83    for role in config
84        .roles
85        .iter()
86        .filter(|r| r.role_type == RoleType::User)
87    {
88        members.push(MemberInstance {
89            name: role.name.clone(),
90            role_name: role.name.clone(),
91            role_type: RoleType::User,
92            agent: None,
93            model: None,
94            prompt: None,
95            posture: None,
96            model_class: None,
97            provider_overlay: None,
98            reports_to: None,
99            use_worktrees: false,
100        });
101    }
102
103    // Phase 2: Add architect instances
104    for role in config
105        .roles
106        .iter()
107        .filter(|r| r.role_type == RoleType::Architect)
108    {
109        let resolved_agent = config.resolve_agent(role);
110        for i in 1..=role.instances {
111            let name = if role.instances == 1 {
112                role.name.clone()
113            } else {
114                format!("{}-{i}", role.name)
115            };
116            members.push(MemberInstance {
117                name: name.clone(),
118                role_name: role.name.clone(),
119                role_type: RoleType::Architect,
120                agent: resolved_member_agent(role, &name, resolved_agent.clone()),
121                model: resolved_member_model(role, &name),
122                prompt: resolved_member_prompt(role, &name),
123                posture: resolved_member_posture(role, &name),
124                model_class: resolved_member_model_class(role, &name),
125                provider_overlay: resolved_member_provider_overlay(role, &name),
126                reports_to: None,
127                use_worktrees: role.use_worktrees,
128            });
129        }
130    }
131
132    // Phase 3: Add manager instances
133    let mut manager_instances = Vec::new();
134    for role in &managers {
135        let resolved_agent = config.resolve_agent(role);
136        for i in 1..=role.instances {
137            let name = if role.instances == 1 {
138                role.name.clone()
139            } else {
140                format!("{}-{i}", role.name)
141            };
142            manager_instances.push((name.clone(), role.name.clone()));
143
144            // Find architect to report to (first architect role, instance 1)
145            let reports_to = config
146                .roles
147                .iter()
148                .find(|r| r.role_type == RoleType::Architect)
149                .map(|a| {
150                    if a.instances == 1 {
151                        a.name.clone()
152                    } else {
153                        format!("{}-1", a.name)
154                    }
155                });
156
157            members.push(MemberInstance {
158                name: name.clone(),
159                role_name: role.name.clone(),
160                role_type: RoleType::Manager,
161                agent: resolved_member_agent(role, &name, resolved_agent.clone()),
162                model: resolved_member_model(role, &name),
163                prompt: resolved_member_prompt(role, &name),
164                posture: resolved_member_posture(role, &name),
165                model_class: resolved_member_model_class(role, &name),
166                provider_overlay: resolved_member_provider_overlay(role, &name),
167                reports_to,
168                use_worktrees: role.use_worktrees,
169            });
170        }
171    }
172
173    let multiple_engineer_roles = engineers.len() > 1;
174
175    // Phase 4: Add engineer instances, partitioned across compatible managers
176    for role in &engineers {
177        let resolved_agent = config.resolve_agent(role);
178        let compatible_managers: Vec<_> = if manager_instances.is_empty() {
179            Vec::new()
180        } else if role.talks_to.is_empty() {
181            manager_instances.iter().collect()
182        } else {
183            manager_instances
184                .iter()
185                .filter(|(member_name, role_name)| {
186                    role.talks_to
187                        .iter()
188                        .any(|target| target == role_name || target == member_name)
189                })
190                .collect()
191        };
192
193        if compatible_managers.is_empty() {
194            // Engineers without managers report to nobody (flat team)
195            for i in 1..=role.instances {
196                let name = if role.instances == 1 {
197                    role.name.clone()
198                } else {
199                    format!("{}-{i}", role.name)
200                };
201                members.push(MemberInstance {
202                    name: name.clone(),
203                    role_name: role.name.clone(),
204                    role_type: RoleType::Engineer,
205                    agent: resolved_member_agent(role, &name, resolved_agent.clone()),
206                    model: resolved_member_model(role, &name),
207                    prompt: resolved_member_prompt(role, &name),
208                    posture: resolved_member_posture(role, &name),
209                    model_class: resolved_member_model_class(role, &name),
210                    provider_overlay: resolved_member_provider_overlay(role, &name),
211                    reports_to: None,
212                    use_worktrees: role.use_worktrees,
213                });
214            }
215        } else {
216            // Multiplicative: each compatible manager gets `instances` engineers
217            for (mgr_idx, (mgr_name, _mgr_role_name)) in compatible_managers.iter().enumerate() {
218                for eng_idx in 1..=role.instances {
219                    let name = engineer_instance_name(
220                        role.name.as_str(),
221                        multiple_engineer_roles,
222                        mgr_idx + 1,
223                        eng_idx,
224                    );
225                    members.push(MemberInstance {
226                        name: name.clone(),
227                        role_name: role.name.clone(),
228                        role_type: RoleType::Engineer,
229                        agent: resolved_member_agent(role, &name, resolved_agent.clone()),
230                        model: resolved_member_model(role, &name),
231                        prompt: resolved_member_prompt(role, &name),
232                        posture: resolved_member_posture(role, &name),
233                        model_class: resolved_member_model_class(role, &name),
234                        provider_overlay: resolved_member_provider_overlay(role, &name),
235                        reports_to: Some(mgr_name.clone()),
236                        use_worktrees: role.use_worktrees,
237                    });
238                }
239            }
240        }
241    }
242
243    if members
244        .iter()
245        .filter(|m| m.role_type != RoleType::User)
246        .count()
247        == 0
248    {
249        bail!("team has no agent members (only user roles)");
250    }
251
252    Ok(members)
253}
254
255fn engineer_instance_name(
256    role_name: &str,
257    multiple_engineer_roles: bool,
258    manager_index: usize,
259    engineer_index: u32,
260) -> String {
261    if !multiple_engineer_roles && role_name == "engineer" {
262        format!("eng-{manager_index}-{engineer_index}")
263    } else {
264        format!("{role_name}-{manager_index}-{engineer_index}")
265    }
266}
267
268fn resolved_member_agent(
269    role: &RoleDef,
270    member_name: &str,
271    resolved_agent: Option<String>,
272) -> Option<String> {
273    role.instance_overrides
274        .get(member_name)
275        .and_then(|override_cfg| override_cfg.agent.clone())
276        .or(resolved_agent)
277}
278
279fn resolved_member_model(role: &RoleDef, member_name: &str) -> Option<String> {
280    role.instance_overrides
281        .get(member_name)
282        .and_then(|override_cfg| override_cfg.model.clone())
283        .or_else(|| role.model.clone())
284}
285
286fn resolved_member_prompt(role: &RoleDef, member_name: &str) -> Option<String> {
287    role.instance_overrides
288        .get(member_name)
289        .and_then(|override_cfg| override_cfg.prompt.clone())
290        .or_else(|| role.prompt.clone())
291}
292
293fn resolved_member_posture(role: &RoleDef, member_name: &str) -> Option<String> {
294    role.instance_overrides
295        .get(member_name)
296        .and_then(|override_cfg| override_cfg.posture.clone())
297        .or_else(|| role.posture.clone())
298}
299
300fn resolved_member_model_class(role: &RoleDef, member_name: &str) -> Option<String> {
301    role.instance_overrides
302        .get(member_name)
303        .and_then(|override_cfg| override_cfg.model_class.clone())
304        .or_else(|| role.model_class.clone())
305}
306
307fn resolved_member_provider_overlay(role: &RoleDef, member_name: &str) -> Option<String> {
308    role.instance_overrides
309        .get(member_name)
310        .and_then(|override_cfg| override_cfg.provider_overlay.clone())
311        .or_else(|| role.provider_overlay.clone())
312}
313
314/// Count total panes needed (excludes user roles which have no pane).
315pub fn pane_count(members: &[MemberInstance]) -> usize {
316    members
317        .iter()
318        .filter(|m| m.role_type != RoleType::User)
319        .count()
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn make_config(yaml: &str) -> TeamConfig {
327        serde_yaml::from_str(yaml).unwrap()
328    }
329
330    #[test]
331    fn simple_team_3_engineers() {
332        let config = make_config(
333            r#"
334name: test
335roles:
336  - name: architect
337    role_type: architect
338    agent: claude
339    instances: 1
340  - name: manager
341    role_type: manager
342    agent: claude
343    instances: 1
344  - name: engineer
345    role_type: engineer
346    agent: codex
347    instances: 3
348"#,
349        );
350        let members = resolve_hierarchy(&config).unwrap();
351        // 1 architect + 1 manager + 3 engineers = 5
352        assert_eq!(members.len(), 5);
353        assert_eq!(pane_count(&members), 5);
354
355        let engineers: Vec<_> = members
356            .iter()
357            .filter(|m| m.role_type == RoleType::Engineer)
358            .collect();
359        assert_eq!(engineers.len(), 3);
360        assert_eq!(engineers[0].name, "eng-1-1");
361        assert_eq!(engineers[1].name, "eng-1-2");
362        assert_eq!(engineers[2].name, "eng-1-3");
363        // All report to manager
364        assert_eq!(engineers[0].reports_to.as_deref(), Some("manager"));
365    }
366
367    #[test]
368    fn large_team_multiplicative() {
369        let config = make_config(
370            r#"
371name: large
372roles:
373  - name: architect
374    role_type: architect
375    agent: claude
376    instances: 1
377  - name: manager
378    role_type: manager
379    agent: claude
380    instances: 3
381  - name: engineer
382    role_type: engineer
383    agent: codex
384    instances: 5
385"#,
386        );
387        let members = resolve_hierarchy(&config).unwrap();
388        // 1 architect + 3 managers + 15 engineers = 19
389        assert_eq!(members.len(), 19);
390        assert_eq!(pane_count(&members), 19);
391
392        let engineers: Vec<_> = members
393            .iter()
394            .filter(|m| m.role_type == RoleType::Engineer)
395            .collect();
396        assert_eq!(engineers.len(), 15);
397        // First manager's engineers
398        assert_eq!(engineers[0].name, "eng-1-1");
399        assert_eq!(engineers[0].reports_to.as_deref(), Some("manager-1"));
400        assert_eq!(engineers[4].name, "eng-1-5");
401        // Second manager's engineers
402        assert_eq!(engineers[5].name, "eng-2-1");
403        assert_eq!(engineers[5].reports_to.as_deref(), Some("manager-2"));
404        // Third manager's engineers
405        assert_eq!(engineers[10].name, "eng-3-1");
406        assert_eq!(engineers[10].reports_to.as_deref(), Some("manager-3"));
407    }
408
409    #[test]
410    fn user_role_excluded_from_pane_count() {
411        let config = make_config(
412            r#"
413name: with-user
414roles:
415  - name: human
416    role_type: user
417    talks_to: [architect]
418  - name: architect
419    role_type: architect
420    agent: claude
421    instances: 1
422"#,
423        );
424        let members = resolve_hierarchy(&config).unwrap();
425        assert_eq!(members.len(), 2);
426        assert_eq!(pane_count(&members), 1);
427    }
428
429    #[test]
430    fn manager_reports_to_architect() {
431        let config = make_config(
432            r#"
433name: test
434roles:
435  - name: arch
436    role_type: architect
437    agent: claude
438    instances: 1
439  - name: mgr
440    role_type: manager
441    agent: claude
442    instances: 2
443"#,
444        );
445        let members = resolve_hierarchy(&config).unwrap();
446        let mgr1 = members.iter().find(|m| m.name == "mgr-1").unwrap();
447        assert_eq!(mgr1.reports_to.as_deref(), Some("arch"));
448    }
449
450    #[test]
451    fn single_instance_no_number_suffix() {
452        let config = make_config(
453            r#"
454name: test
455roles:
456  - name: architect
457    role_type: architect
458    agent: claude
459    instances: 1
460"#,
461        );
462        let members = resolve_hierarchy(&config).unwrap();
463        assert_eq!(members[0].name, "architect");
464    }
465
466    #[test]
467    fn multi_instance_has_number_suffix() {
468        let config = make_config(
469            r#"
470name: test
471roles:
472  - name: manager
473    role_type: manager
474    agent: claude
475    instances: 2
476"#,
477        );
478        let members = resolve_hierarchy(&config).unwrap();
479        assert_eq!(members[0].name, "manager-1");
480        assert_eq!(members[1].name, "manager-2");
481    }
482
483    #[test]
484    fn engineers_without_managers_report_to_nobody() {
485        let config = make_config(
486            r#"
487name: flat
488roles:
489  - name: worker
490    role_type: engineer
491    agent: codex
492    instances: 3
493"#,
494        );
495        let members = resolve_hierarchy(&config).unwrap();
496        assert_eq!(members.len(), 3);
497        for m in &members {
498            assert!(m.reports_to.is_none());
499        }
500        assert_eq!(members[0].name, "worker-1");
501    }
502
503    #[test]
504    fn rejects_user_only_team() {
505        let config = make_config(
506            r#"
507name: empty
508roles:
509  - name: human
510    role_type: user
511"#,
512        );
513        let err = resolve_hierarchy(&config).unwrap_err().to_string();
514        assert!(err.contains("no agent members"));
515    }
516
517    #[test]
518    fn engineer_roles_can_target_specific_manager_roles() {
519        let config = make_config(
520            r#"
521name: split-team
522roles:
523  - name: architect
524    role_type: architect
525    agent: claude
526  - name: black-lead
527    role_type: manager
528    agent: claude
529    talks_to: [architect, black-eng]
530  - name: red-lead
531    role_type: manager
532    agent: claude
533    talks_to: [architect, red-eng]
534  - name: black-eng
535    role_type: engineer
536    agent: codex
537    instances: 3
538    talks_to: [black-lead]
539  - name: red-eng
540    role_type: engineer
541    agent: codex
542    instances: 3
543    talks_to: [red-lead]
544"#,
545        );
546
547        let members = resolve_hierarchy(&config).unwrap();
548        let engineers: Vec<_> = members
549            .iter()
550            .filter(|m| m.role_type == RoleType::Engineer)
551            .collect();
552
553        assert_eq!(engineers.len(), 6);
554        assert_eq!(
555            engineers
556                .iter()
557                .filter(|m| m.role_name == "black-eng")
558                .count(),
559            3
560        );
561        assert_eq!(
562            engineers
563                .iter()
564                .filter(|m| m.role_name == "red-eng")
565                .count(),
566            3
567        );
568        assert!(engineers.iter().all(|m| {
569            if m.role_name == "black-eng" {
570                m.reports_to.as_deref() == Some("black-lead")
571            } else {
572                m.reports_to.as_deref() == Some("red-lead")
573            }
574        }));
575
576        let unique_names: std::collections::HashSet<_> =
577            engineers.iter().map(|m| m.name.as_str()).collect();
578        assert_eq!(unique_names.len(), engineers.len());
579        assert!(unique_names.contains("black-eng-1-1"));
580        assert!(unique_names.contains("red-eng-1-1"));
581    }
582
583    #[test]
584    fn engineer_role_without_matching_manager_talks_to_stays_flat() {
585        let config = make_config(
586            r#"
587name: unmatched
588roles:
589  - name: architect
590    role_type: architect
591    agent: claude
592  - name: manager
593    role_type: manager
594    agent: claude
595  - name: specialist
596    role_type: engineer
597    agent: codex
598    instances: 2
599    talks_to: [architect]
600"#,
601        );
602
603        let members = resolve_hierarchy(&config).unwrap();
604        let engineers: Vec<_> = members
605            .iter()
606            .filter(|m| m.role_type == RoleType::Engineer)
607            .collect();
608
609        assert_eq!(engineers.len(), 2);
610        assert!(engineers.iter().all(|m| m.reports_to.is_none()));
611        assert_eq!(engineers[0].name, "specialist-1");
612        assert_eq!(engineers[1].name, "specialist-2");
613    }
614
615    #[test]
616    fn team_level_agent_propagates_to_members() {
617        let config = make_config(
618            r#"
619name: team-default
620agent: codex
621roles:
622  - name: architect
623    role_type: architect
624  - name: manager
625    role_type: manager
626  - name: engineer
627    role_type: engineer
628    instances: 2
629"#,
630        );
631        let members = resolve_hierarchy(&config).unwrap();
632        // All non-user members should have the team default agent
633        for m in &members {
634            assert_eq!(
635                m.agent.as_deref(),
636                Some("codex"),
637                "member {} should have team default agent 'codex'",
638                m.name
639            );
640        }
641    }
642
643    #[test]
644    fn role_agent_overrides_team_default() {
645        let config = make_config(
646            r#"
647name: mixed
648agent: codex
649roles:
650  - name: architect
651    role_type: architect
652    agent: claude
653  - name: manager
654    role_type: manager
655  - name: engineer
656    role_type: engineer
657    instances: 2
658"#,
659        );
660        let members = resolve_hierarchy(&config).unwrap();
661        let architect = members.iter().find(|m| m.name == "architect").unwrap();
662        assert_eq!(
663            architect.agent.as_deref(),
664            Some("claude"),
665            "architect should use role-level override"
666        );
667        let manager = members.iter().find(|m| m.name == "manager").unwrap();
668        assert_eq!(
669            manager.agent.as_deref(),
670            Some("codex"),
671            "manager should use team default"
672        );
673    }
674
675    #[test]
676    fn mixed_backend_engineers_under_same_manager() {
677        let config = make_config(
678            r#"
679name: mixed-eng
680agent: codex
681roles:
682  - name: architect
683    role_type: architect
684    agent: claude
685  - name: manager
686    role_type: manager
687    agent: claude
688  - name: claude-eng
689    role_type: engineer
690    agent: claude
691    instances: 2
692    talks_to: [manager]
693  - name: codex-eng
694    role_type: engineer
695    instances: 2
696    talks_to: [manager]
697"#,
698        );
699        let members = resolve_hierarchy(&config).unwrap();
700        let claude_engs: Vec<_> = members
701            .iter()
702            .filter(|m| m.role_name == "claude-eng")
703            .collect();
704        let codex_engs: Vec<_> = members
705            .iter()
706            .filter(|m| m.role_name == "codex-eng")
707            .collect();
708
709        assert_eq!(claude_engs.len(), 2);
710        assert_eq!(codex_engs.len(), 2);
711
712        for m in &claude_engs {
713            assert_eq!(m.agent.as_deref(), Some("claude"));
714            assert_eq!(m.reports_to.as_deref(), Some("manager"));
715        }
716        for m in &codex_engs {
717            assert_eq!(m.agent.as_deref(), Some("codex"));
718            assert_eq!(m.reports_to.as_deref(), Some("manager"));
719        }
720    }
721
722    #[test]
723    fn no_team_agent_defaults_to_claude() {
724        let config = make_config(
725            r#"
726name: default-fallback
727roles:
728  - name: worker
729    role_type: engineer
730    agent: claude
731    instances: 1
732"#,
733        );
734        let members = resolve_hierarchy(&config).unwrap();
735        assert_eq!(members[0].agent.as_deref(), Some("claude"));
736    }
737
738    #[test]
739    fn instance_overrides_apply_to_named_member() {
740        let config = make_config(
741            r#"
742name: overrides
743roles:
744  - name: manager
745    role_type: manager
746    agent: claude
747  - name: engineer
748    role_type: engineer
749    agent: codex
750    posture: deep_worker
751    model_class: standard
752    instances: 2
753    instance_overrides:
754      eng-1-1:
755        agent: claude
756        model: claude-opus-4-1
757        model_class: frontier
758        posture: fast_lane
759"#,
760        );
761
762        let members = resolve_hierarchy(&config).unwrap();
763        let overridden = members.iter().find(|m| m.name == "eng-1-1").unwrap();
764        let inherited = members.iter().find(|m| m.name == "eng-1-2").unwrap();
765
766        assert_eq!(overridden.agent.as_deref(), Some("claude"));
767        assert_eq!(overridden.model.as_deref(), Some("claude-opus-4-1"));
768        assert_eq!(overridden.model_class.as_deref(), Some("frontier"));
769        assert_eq!(overridden.posture.as_deref(), Some("fast_lane"));
770
771        assert_eq!(inherited.agent.as_deref(), Some("codex"));
772        assert_eq!(inherited.model_class.as_deref(), Some("standard"));
773        assert_eq!(inherited.posture.as_deref(), Some("deep_worker"));
774    }
775
776    #[test]
777    fn resolved_member_agent_prefers_instance_override_then_role_then_team() {
778        let config = make_config(
779            r#"
780name: override-chain
781agent: claude
782roles:
783  - name: architect
784    role_type: architect
785    agent: claude
786  - name: manager
787    role_type: manager
788  - name: role-eng
789    role_type: engineer
790    agent: codex
791    instances: 2
792    talks_to: [manager]
793    instance_overrides:
794      role-eng-1-1:
795        agent: kiro
796  - name: team-eng
797    role_type: engineer
798    instances: 1
799    talks_to: [manager]
800"#,
801        );
802
803        let members = resolve_hierarchy(&config).unwrap();
804        let override_member = members.iter().find(|m| m.name == "role-eng-1-1").unwrap();
805        let role_member = members.iter().find(|m| m.name == "role-eng-1-2").unwrap();
806        let team_member = members.iter().find(|m| m.name == "team-eng-1-1").unwrap();
807
808        assert_eq!(override_member.agent.as_deref(), Some("kiro"));
809        assert_eq!(role_member.agent.as_deref(), Some("codex"));
810        assert_eq!(team_member.agent.as_deref(), Some("claude"));
811    }
812
813    #[test]
814    fn mixed_team_instance_overrides_resolve_per_member_agents() {
815        let config = make_config(
816            r#"
817name: mixed-providers
818agent: claude
819roles:
820  - name: architect
821    role_type: architect
822    agent: claude
823  - name: manager
824    role_type: manager
825    agent: claude
826  - name: engineer
827    role_type: engineer
828    agent: codex
829    instances: 3
830    talks_to: [manager]
831    instance_overrides:
832      eng-1-2:
833        agent: kiro
834        model: claude-opus-4.6-1m
835      eng-1-3:
836        agent: gemini
837"#,
838        );
839
840        let members = resolve_hierarchy(&config).unwrap();
841        let eng_1 = members.iter().find(|m| m.name == "eng-1-1").unwrap();
842        let eng_2 = members.iter().find(|m| m.name == "eng-1-2").unwrap();
843        let eng_3 = members.iter().find(|m| m.name == "eng-1-3").unwrap();
844
845        assert_eq!(eng_1.agent.as_deref(), Some("codex"));
846        assert_eq!(eng_2.agent.as_deref(), Some("kiro"));
847        assert_eq!(eng_2.model.as_deref(), Some("claude-opus-4.6-1m"));
848        assert_eq!(eng_3.agent.as_deref(), Some("gemini"));
849    }
850}