feat(core): integrate TeamRegistry into Config and support multi-level discovery

- Update Config to manage and initialize TeamRegistry
- Support team discovery from both global (~/.gemini/teams) and project-level directories
- Implement reload logic for teams during agent registry refresh
- Add getActiveTeam and setActiveTeam accessors to Config
- Add integration tests for Config and TeamRegistry discovery
This commit is contained in:
Taylor Mullen
2026-04-01 15:20:24 -07:00
parent ef80628b86
commit cce060646c
5 changed files with 164 additions and 32 deletions
+29 -16
View File
@@ -7,6 +7,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TeamRegistry } from './teamRegistry.js';
import { type Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { type AgentRegistry } from './registry.js';
import { loadTeamsFromDirectory } from './teamLoader.js';
import { type TeamDefinition, type AgentDefinition } from './types.js';
@@ -21,6 +22,10 @@ describe('TeamRegistry', () => {
let registry: TeamRegistry;
beforeEach(() => {
vi.spyOn(Storage, 'getUserTeamsDir').mockReturnValue(
'/mock/user/.gemini/teams',
);
mockConfig = {
isAgentsEnabled: vi.fn().mockReturnValue(true),
getFolderTrust: vi.fn().mockReturnValue(false),
@@ -40,6 +45,11 @@ describe('TeamRegistry', () => {
registry = new TeamRegistry(mockConfig, mockAgentRegistry);
vi.mocked(loadTeamsFromDirectory).mockReset();
// Default mock behavior to return empty result
vi.mocked(loadTeamsFromDirectory).mockResolvedValue({
teams: [],
errors: [],
});
});
it('should load teams and register agents on initialize', async () => {
@@ -58,20 +68,24 @@ describe('TeamRegistry', () => {
agents: [mockAgent],
};
vi.mocked(loadTeamsFromDirectory).mockResolvedValue({
teams: [mockTeam],
errors: [],
});
// First call for user teams (empty), second for project teams
vi.mocked(loadTeamsFromDirectory)
.mockResolvedValueOnce({ teams: [], errors: [] })
.mockResolvedValueOnce({
teams: [mockTeam],
errors: [],
});
await registry.initialize();
expect(registry.getAllTeams()).toHaveLength(1);
expect(registry.getTeam('test-team')).toEqual(mockTeam);
expect(mockAgentRegistry.registerAgent).toHaveBeenCalledWith(mockAgent);
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(2);
});
it('should not load teams if agents are disabled', async () => {
mockConfig.isAgentsEnabled.mockReturnValue(false);
vi.mocked(mockConfig.isAgentsEnabled).mockReturnValue(false);
await registry.initialize();
@@ -79,13 +93,17 @@ describe('TeamRegistry', () => {
expect(registry.getAllTeams()).toHaveLength(0);
});
it('should skip project teams in untrusted folder', async () => {
mockConfig.getFolderTrust.mockReturnValue(true);
mockConfig.isTrustedFolder.mockReturnValue(false);
it('should skip project teams in untrusted folder but still load user teams', async () => {
vi.mocked(mockConfig.getFolderTrust).mockReturnValue(true);
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
await registry.initialize();
expect(loadTeamsFromDirectory).not.toHaveBeenCalled();
// Should only be called once for user teams
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(1);
expect(loadTeamsFromDirectory).toHaveBeenCalledWith(
'/mock/user/.gemini/teams',
);
});
it('should manage active team', async () => {
@@ -115,15 +133,10 @@ describe('TeamRegistry', () => {
});
it('should reload teams', async () => {
vi.mocked(loadTeamsFromDirectory).mockResolvedValue({
teams: [],
errors: [],
});
await registry.initialize();
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(1);
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(2);
await registry.reload();
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(2);
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(4);
});
});
+22 -15
View File
@@ -6,8 +6,9 @@
import { type TeamDefinition } from './types.js';
import { type Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { type AgentRegistry } from './registry.js';
import { loadTeamsFromDirectory } from './teamLoader.js';
import { loadTeamsFromDirectory, type TeamLoadResult } from './teamLoader.js';
import { coreEvents } from '../utils/events.js';
import { debugLogger } from '../utils/debugLogger.js';
@@ -24,7 +25,7 @@ export class TeamRegistry {
) {}
/**
* Discovers and loads teams from the project's .gemini/teams/ directory.
* Discovers and loads teams from the global and project-level directories.
*/
async initialize(): Promise<void> {
await this.loadTeams();
@@ -44,6 +45,11 @@ export class TeamRegistry {
return;
}
// Load user-level teams first
const userTeamsDir = Storage.getUserTeamsDir();
const userLoadResult = await loadTeamsFromDirectory(userTeamsDir);
this.processLoadResult(userLoadResult);
const folderTrustEnabled = this.config.getFolderTrust();
const isTrustedFolder = this.config.isTrustedFolder();
@@ -55,25 +61,30 @@ export class TeamRegistry {
'info',
'Skipping project teams due to untrusted folder. To enable, ensure that the project root is trusted.',
);
return;
} else {
// Load project-level teams (takes precedence over user-level if names collide)
const projectTeamsDir = this.config.storage.getProjectTeamsDir();
const projectLoadResult = await loadTeamsFromDirectory(projectTeamsDir);
this.processLoadResult(projectLoadResult);
}
const projectTeamsDir = this.config.storage.getProjectTeamsDir();
const loadResult = await loadTeamsFromDirectory(projectTeamsDir);
if (this.config.getDebugMode()) {
debugLogger.log(`[TeamRegistry] Loaded with ${this.teams.size} teams.`);
}
}
for (const error of loadResult.errors) {
private processLoadResult(result: TeamLoadResult): void {
for (const error of result.errors) {
debugLogger.warn(`[TeamRegistry] Error loading team: ${error.message}`);
coreEvents.emitFeedback('error', `Team loading error: ${error.message}`);
}
for (const team of loadResult.teams) {
for (const team of result.teams) {
this.teams.set(team.name, team);
// Register team agents in the global AgentRegistry so they are available as SubagentTools
for (const agent of team.agents) {
try {
await this.agentRegistry.registerAgent(agent);
} catch (e) {
this.agentRegistry.registerAgent(agent).catch((e) => {
debugLogger.warn(
`[TeamRegistry] Error registering agent "${agent.name}" from team "${team.name}":`,
e,
@@ -82,13 +93,9 @@ export class TeamRegistry {
'error',
`Error registering agent "${agent.name}" from team "${team.name}": ${e instanceof Error ? e.message : String(e)}`,
);
}
});
}
}
if (this.config.getDebugMode()) {
debugLogger.log(`[TeamRegistry] Loaded with ${this.teams.size} teams.`);
}
}
/**
@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { Config } from './config.js';
import { Storage } from './storage.js';
describe('Config Team Integration', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'config-team-test-'));
// Setup .gemini/teams structure
const teamsDir = path.join(tempDir, '.gemini', 'teams', 'test-team');
await fs.mkdir(teamsDir, { recursive: true });
await fs.writeFile(
path.join(teamsDir, 'TEAM.md'),
`---
name: test-team
display_name: Test Team
description: A test team
---
Instructions`,
);
const agentsDir = path.join(teamsDir, 'agents');
await fs.mkdir(agentsDir, { recursive: true });
await fs.writeFile(
path.join(agentsDir, 'team-agent.md'),
`---
name: team-agent
description: Team agent
---
Prompt`,
);
// Mock global teams dir to be empty for isolation
const globalTeamsDir = path.join(tempDir, 'global-teams');
await fs.mkdir(globalTeamsDir, { recursive: true });
vi.spyOn(Storage, 'getUserTeamsDir').mockReturnValue(globalTeamsDir);
});
afterEach(async () => {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
vi.restoreAllMocks();
});
it('should discover and load teams during initialization', async () => {
const config = new Config({
sessionId: 'test-session',
targetDir: tempDir,
debugMode: false,
model: 'gemini-1.5-flash',
cwd: tempDir,
});
await config.initialize();
const teamRegistry = config.getTeamRegistry();
const teams = teamRegistry.getAllTeams();
expect(teams).toHaveLength(1);
expect(teams[0].name).toBe('test-team');
const agentRegistry = config.getAgentRegistry();
const agent = agentRegistry.getDiscoveredDefinition('team-agent');
expect(agent).toBeDefined();
expect(agent?.name).toBe('team-agent');
});
it('should delegate active team management', async () => {
const config = new Config({
sessionId: 'test-session',
targetDir: tempDir,
debugMode: false,
model: 'gemini-1.5-flash',
cwd: tempDir,
});
await config.initialize();
expect(config.getActiveTeam()).toBeUndefined();
config.setActiveTeam('test-team');
expect(config.getActiveTeam()?.name).toBe('test-team');
});
});
+16 -1
View File
@@ -151,6 +151,7 @@ import {
} from '../code_assist/experiments/experiments.js';
import { AgentRegistry } from '../agents/registry.js';
import { TeamRegistry } from '../agents/teamRegistry.js';
import type { AgentDefinition, TeamDefinition } from '../agents/types.js';
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
import { setGlobalProxy } from '../utils/fetch.js';
import { SubagentTool } from '../agents/subagent-tool.js';
@@ -158,7 +159,6 @@ import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
import { startupProfiler } from '../telemetry/startupProfiler.js';
import type { AgentDefinition } from '../agents/types.js';
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import { InjectionService } from './injectionService.js';
@@ -2035,6 +2035,20 @@ export class Config implements McpContext, AgentLoopContext {
return this.teamRegistry;
}
getActiveTeam(): TeamDefinition | undefined {
return this.teamRegistry.getActiveTeam();
}
setActiveTeam(name: string | undefined): void {
if (name) {
this.teamRegistry.setActiveTeam(name);
} else {
// Logic for clearing active team could be added here if needed,
// but for now TeamRegistry.setActiveTeam expects a string.
// We'll leave it as is to match TeamRegistry signature or update TeamRegistry if null is allowed.
}
}
getAcknowledgedAgentsService(): AcknowledgedAgentsService {
return this.acknowledgedAgentsService;
}
@@ -3716,6 +3730,7 @@ export class Config implements McpContext, AgentLoopContext {
}
private onAgentsRefreshed = async () => {
await this.teamRegistry.reload();
if (this._toolRegistry) {
this.registerSubAgentTools(this._toolRegistry);
}
+4
View File
@@ -106,6 +106,10 @@ export class Storage {
return path.join(Storage.getGlobalGeminiDir(), 'agents');
}
static getUserTeamsDir(): string {
return path.join(Storage.getGlobalGeminiDir(), 'teams');
}
static getAcknowledgedAgentsPath(): string {
return path.join(
Storage.getGlobalGeminiDir(),