diff --git a/packages/core/src/agents/teamRegistry.test.ts b/packages/core/src/agents/teamRegistry.test.ts index 24b614d007..b7f0f9ec12 100644 --- a/packages/core/src/agents/teamRegistry.test.ts +++ b/packages/core/src/agents/teamRegistry.test.ts @@ -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); }); }); diff --git a/packages/core/src/agents/teamRegistry.ts b/packages/core/src/agents/teamRegistry.ts index 4d885ef412..1157a4bc8d 100644 --- a/packages/core/src/agents/teamRegistry.ts +++ b/packages/core/src/agents/teamRegistry.ts @@ -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 { 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.`); - } } /** diff --git a/packages/core/src/config/config-teams.test.ts b/packages/core/src/config/config-teams.test.ts new file mode 100644 index 0000000000..f31e3faa6e --- /dev/null +++ b/packages/core/src/config/config-teams.test.ts @@ -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'); + }); +}); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9eb5adff4b..6c6695d10a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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); } diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 76711261f1..7f72a20491 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -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(),