mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-12 04:17:15 -07:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user