Skip to content

Commit cf9a3ce

Browse files
committed
feat: add preference for a chat's custom instructions loading
1 parent f94e037 commit cf9a3ce

12 files changed

Lines changed: 392 additions & 6 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
package com.microsoft.copilot.eclipse.core;
5+
6+
import static org.junit.jupiter.api.Assertions.assertEquals;
7+
import static org.junit.jupiter.api.Assertions.assertThrows;
8+
9+
import org.junit.jupiter.params.ParameterizedTest;
10+
import org.junit.jupiter.params.provider.EnumSource;
11+
import org.junit.jupiter.params.provider.NullSource;
12+
import org.junit.jupiter.params.provider.ValueSource;
13+
14+
import com.microsoft.copilot.eclipse.core.Constants.CustomInstructionsChatLoadScope;
15+
16+
class CustomInstructionsChatLoadScopeTest {
17+
18+
@ParameterizedTest
19+
@EnumSource(CustomInstructionsChatLoadScope.class)
20+
void testStringToEnumEntryConversion(CustomInstructionsChatLoadScope enumEntry) {
21+
String inputValue = enumEntry.getValue();
22+
23+
CustomInstructionsChatLoadScope actualResult = CustomInstructionsChatLoadScope.fromValue(inputValue);
24+
25+
assertEquals(enumEntry, actualResult);
26+
}
27+
28+
@ParameterizedTest
29+
@ValueSource(strings = { "wrongValue" })
30+
@NullSource
31+
void testStringToEnumEntryConversionThrowsExceptionForWrongValues(String value) {
32+
assertThrows(IllegalArgumentException.class, () -> CustomInstructionsChatLoadScope.fromValue(value));
33+
}
34+
35+
}

com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ private Constants() {
3737
public static final String CUSTOM_INSTRUCTIONS_WORKSPACE = "customInstructionsWorkspace";
3838
public static final String CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED = "customInstructionsWorkspaceEnabled";
3939
public static final String CUSTOM_INSTRUCTIONS_GIT_COMMIT = "customInstructionsGitCommit";
40+
public static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE = "customInstructionsChatLoadScope";
41+
private static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_ALL = "allProjects";
42+
private static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_REFERENCED = "referencedProjects";
4043
public static final String GITHUB_COPILOT_URL = "http://github.com";
4144
@Deprecated
4245
public static final String QUICK_START_VERSION = "quickStartVersion";
@@ -73,4 +76,52 @@ private Constants() {
7376
.of(Stream.concat(Stream.concat(BASE_EXCLUDED_FILE_TYPES.stream(), ADDITIONAL_EXCLUDED_FILE_TYPES.stream()),
7477
ALLOWED_IMAGE_EXTENSIONS.stream()).toArray(String[]::new));
7578

79+
/**
80+
* Scope loading modes for custom instructions in GitHub Copilot chat.
81+
* <ul>
82+
* <li><b>ALL_PROJECTS</b>: Load custom instructions from all projects in the Eclipse workspace
83+
* <li><b>REFERENCED_PROJECTS</b>: Load custom instructions only from parent projects of files/folders
84+
* referenced in the Copilot chat
85+
* </ul>
86+
*/
87+
public enum CustomInstructionsChatLoadScope {
88+
ALL_PROJECTS(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_ALL),
89+
REFERENCED_PROJECTS(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_REFERENCED);
90+
91+
public static final CustomInstructionsChatLoadScope DEFAULT_VALUE =
92+
CustomInstructionsChatLoadScope.ALL_PROJECTS;
93+
94+
private final String value;
95+
96+
CustomInstructionsChatLoadScope(String value) {
97+
this.value = value;
98+
}
99+
100+
/**
101+
* Returns the string value representing this enum entry for preference serialization.
102+
*
103+
* @return the string value for this preference setting
104+
*/
105+
public String getValue() {
106+
return value;
107+
}
108+
109+
/**
110+
* Retrieves the enum constant corresponding to the given string value
111+
* if available, otherwise an {@link IllegalArgumentException} is thrown.
112+
*
113+
* @param value the string value (preference) representing an enum entry
114+
* @return the enum entry representing the given value
115+
* @throws IllegalArgumentException if the value does not correspond to any enum entry
116+
*/
117+
public static CustomInstructionsChatLoadScope fromValue(String value) {
118+
for (CustomInstructionsChatLoadScope scope : values()) {
119+
if (scope.getValue().equals(value)) {
120+
return scope;
121+
}
122+
}
123+
throw new IllegalArgumentException("Unknown CustomInstructionsLoadScope value: " + value);
124+
}
125+
}
126+
76127
}

com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.eclipse.lsp4j.ProgressParams;
2121
import org.eclipse.lsp4j.Range;
2222
import org.eclipse.lsp4j.TextDocumentIdentifier;
23+
import org.eclipse.lsp4j.WorkspaceFolder;
2324
import org.eclipse.lsp4j.jsonrpc.Endpoint;
2425
import org.eclipse.lsp4j.jsonrpc.messages.Either;
2526
import org.eclipse.lsp4j.services.LanguageServer;
@@ -275,6 +276,19 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok
275276
List<IResource> files, IFile currentFile, Range currentSelection, List<Turn> turns, CopilotModel activeModel,
276277
String chatModeName, String customChatModeId, List<TodoItem> todos, String agentSlug,
277278
String agentJobWorkspaceFolder) {
279+
return createConversation(workDoneToken, message, files, currentFile, currentSelection, turns, activeModel,
280+
chatModeName, customChatModeId, todos, agentSlug, agentJobWorkspaceFolder,
281+
LSPEclipseUtils.getWorkspaceFolders());
282+
}
283+
284+
/**
285+
* Create a conversation with the given parameters, including optional workspace folders argument.
286+
*/
287+
public CompletableFuture<ChatCreateResult> createConversation(String workDoneToken, String message,
288+
List<IResource> files, IFile currentFile, Range currentSelection, List<Turn> turns, CopilotModel activeModel,
289+
String chatModeName, String customChatModeId, List<TodoItem> todos, String agentSlug,
290+
String agentJobWorkspaceFolder, List<WorkspaceFolder> workspaceFolders) {
291+
278292
boolean supportVision = activeModel.getCapabilities().supports().vision();
279293
Either<String, List<ChatCompletionContentPart>> messageWithImages = ChatMessageUtils
280294
.createMessageWithImages(message, FileUtils.filterFilesFrom(files), supportVision);
@@ -288,7 +302,7 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok
288302

289303
if (StringUtils.isBlank(agentSlug)) {
290304
param.setWorkspaceFolder(PlatformUtils.getWorkspaceRootUri());
291-
param.setWorkspaceFolders(LSPEclipseUtils.getWorkspaceFolders());
305+
param.setWorkspaceFolders(workspaceFolders);
292306
param.setTodoList(todos);
293307
} else {
294308
// Set agentSlug if provided - this will modify the first turn's agentSlug
@@ -333,6 +347,19 @@ public CompletableFuture<ChatTurnResult> addConversationTurn(String workDoneToke
333347
String message, List<IResource> files, IFile currentFile, Range currentSelection, CopilotModel activeModel,
334348
String chatModeName, String customChatModeId, List<TodoItem> todoList, String agentSlug,
335349
String agentJobWorkspaceFolder) {
350+
return addConversationTurn(workDoneToken, conversationId, message, files, currentFile, currentSelection,
351+
activeModel, chatModeName, customChatModeId, todoList, agentSlug, agentJobWorkspaceFolder,
352+
LSPEclipseUtils.getWorkspaceFolders());
353+
}
354+
355+
/**
356+
* Create a conversation turn with the given parameters, including optional workspace folders argument.
357+
*/
358+
public CompletableFuture<ChatTurnResult> addConversationTurn(String workDoneToken, String conversationId,
359+
String message, List<IResource> files, IFile currentFile, Range currentSelection, CopilotModel activeModel,
360+
String chatModeName, String customChatModeId, List<TodoItem> todoList, String agentSlug,
361+
String agentJobWorkspaceFolder, List<WorkspaceFolder> workspaceFolders) {
362+
336363
boolean supportVision = activeModel.getCapabilities().supports().vision();
337364
Either<String, List<ChatCompletionContentPart>> messageWithImages = ChatMessageUtils
338365
.createMessageWithImages(message, FileUtils.filterFilesFrom(files), supportVision);
@@ -346,7 +373,7 @@ public CompletableFuture<ChatTurnResult> addConversationTurn(String workDoneToke
346373

347374
if (StringUtils.isBlank(agentSlug)) {
348375
param.setWorkspaceFolder(PlatformUtils.getWorkspaceRootUri());
349-
param.setWorkspaceFolders(LSPEclipseUtils.getWorkspaceFolders());
376+
param.setWorkspaceFolders(workspaceFolders);
350377
param.setTodoList(todoList);
351378
} else {
352379
param.setAgentSlug(agentSlug);

com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2",
1212
org.eclipse.lsp4e;bundle-version="0.18.1",
1313
org.eclipse.jdt.annotation;resolution:=optional,
1414
junit-jupiter-api;bundle-version="5.10.1",
15+
junit-jupiter-params;bundle-version="5.10.1",
1516
org.mockito.junit-jupiter;bundle-version="5.10.2",
1617
com.microsoft.copilot.eclipse.core;bundle-version="0.15.0",
1718
com.microsoft.copilot.eclipse.ui;bundle-version="0.15.0",

com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,25 @@
77
import static org.mockito.Mockito.*;
88

99
import java.util.Arrays;
10+
import java.util.List;
11+
import java.util.stream.Stream;
1012

1113
import org.eclipse.core.resources.IFile;
1214
import org.eclipse.core.resources.IFolder;
15+
import org.eclipse.core.resources.IProject;
16+
import org.eclipse.core.resources.IResource;
1317
import org.eclipse.jface.viewers.StructuredSelection;
18+
import org.eclipse.lsp4e.LSPEclipseUtils;
19+
import org.eclipse.lsp4j.WorkspaceFolder;
1420
import org.junit.jupiter.api.AfterEach;
1521
import org.junit.jupiter.api.BeforeEach;
1622
import org.junit.jupiter.api.Test;
1723
import org.junit.jupiter.api.extension.ExtendWith;
1824
import org.mockito.Mock;
1925
import org.mockito.MockedStatic;
2026
import org.mockito.junit.jupiter.MockitoExtension;
27+
import org.junit.jupiter.params.ParameterizedTest;
28+
import org.junit.jupiter.params.provider.MethodSource;
2129

2230
import com.microsoft.copilot.eclipse.core.utils.FileUtils;
2331

@@ -35,21 +43,40 @@ class ResourceUtilsTest {
3543

3644
@Mock
3745
private IFolder mockFolder;
46+
47+
@Mock
48+
private IProject mockProjectA;
49+
50+
@Mock
51+
private IProject mockProjectB;
3852

3953
private MockedStatic<FileUtils> mockedFileUtils;
54+
private MockedStatic<LSPEclipseUtils> mockedLspUtils;
55+
56+
private static final String nameProjectA= "ProjectA";
57+
private static final String nameProjectB= "ProjectB";
4058

4159
@BeforeEach
4260
void setUp() {
4361
mockedFileUtils = mockStatic(FileUtils.class);
4462
mockedFileUtils.when(() -> FileUtils.isExcludedFromReferencedFiles(mockValidFile)).thenReturn(false);
4563
mockedFileUtils.when(() -> FileUtils.isExcludedFromReferencedFiles(mockInvalidFile)).thenReturn(true);
64+
65+
mockedLspUtils = mockStatic(LSPEclipseUtils.class);
66+
mockedLspUtils.when(() -> LSPEclipseUtils.toWorkspaceFolder(mockProjectA))
67+
.thenReturn(new WorkspaceFolder("file:///" + nameProjectA, nameProjectA));
68+
mockedLspUtils.when(() -> LSPEclipseUtils.toWorkspaceFolder(mockProjectB))
69+
.thenReturn(new WorkspaceFolder("file:///" + nameProjectB, nameProjectB));
4670
}
4771

4872
@AfterEach
4973
void tearDown() {
5074
if (mockedFileUtils != null) {
5175
mockedFileUtils.close();
5276
}
77+
if (mockedLspUtils != null) {
78+
mockedLspUtils.close();
79+
}
5380
}
5481

5582
@Test
@@ -82,4 +109,52 @@ void testCollectValidResourcesWithMocks() {
82109
assertTrue(validResources.contains(mockFolder), "Should contain folder");
83110
assertFalse(validResources.contains(mockInvalidFile), "Should not contain excluded file");
84111
}
112+
113+
private static Stream<List<IResource>> provideResourcesForNeverNullTest() {
114+
return Stream.of(
115+
null,
116+
List.of(),
117+
Arrays.asList((IResource) null),
118+
List.of(mock(IFolder.class)),
119+
List.of(mock(IProject.class))
120+
);
121+
}
122+
123+
@ParameterizedTest
124+
@MethodSource("provideResourcesForNeverNullTest")
125+
void testDeriveWorkspaceFoldersReturnsNeverNull(List<IResource> resources) {
126+
List<WorkspaceFolder> result = ResourceUtils.deriveWorkspaceFoldersFrom(resources);
127+
128+
assertNotNull(result);
129+
assertTrue(result.isEmpty());
130+
}
131+
132+
@Test
133+
void testDeriveWorkspaceFoldersWithMultipleResources() {
134+
when(mockValidFile.getProject()).thenReturn(mockProjectA);
135+
when(mockFolder.getProject()).thenReturn(mockProjectB);
136+
when(mockProjectA.isAccessible()).thenReturn(true);
137+
when(mockProjectB.isAccessible()).thenReturn(true);
138+
139+
List<WorkspaceFolder> result = ResourceUtils.deriveWorkspaceFoldersFrom(List.of(mockValidFile, mockFolder));
140+
141+
assertNotNull(result);
142+
assertFalse(result.isEmpty());
143+
assertEquals(2, result.size(), "Both projects from both resources should be derived as workspace folders");
144+
}
145+
146+
@Test
147+
void testDeriveWorkspaceFoldersDoesNotReturnDuplicates() {
148+
when(mockValidFile.getProject()).thenReturn(mockProjectA);
149+
when(mockFolder.getProject()).thenReturn(mockProjectA);
150+
when(mockProjectA.isAccessible()).thenReturn(true);
151+
when(mockProjectA.getName()).thenReturn(nameProjectA);
152+
153+
List<WorkspaceFolder> result = ResourceUtils.deriveWorkspaceFoldersFrom(List.of(mockValidFile, mockFolder));
154+
155+
assertNotNull(result);
156+
assertFalse(result.isEmpty());
157+
assertEquals(1, result.size(), "Projects in derived workspaces folders should be unique, no duplicates.");
158+
assertEquals(mockProjectA.getName(), result.get(0).getName());
159+
}
85160
}

com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.util.UUID;
1111
import java.util.concurrent.CompletableFuture;
1212
import java.util.concurrent.ExecutionException;
13+
import java.util.stream.Stream;
1314

1415
import org.apache.commons.lang3.StringUtils;
1516
import org.eclipse.core.resources.IFile;
@@ -21,7 +22,9 @@
2122
import org.eclipse.e4.core.services.events.IEventBroker;
2223
import org.eclipse.jdt.annotation.Nullable;
2324
import org.eclipse.jface.preference.IPreferenceStore;
25+
import org.eclipse.lsp4e.LSPEclipseUtils;
2426
import org.eclipse.lsp4j.Range;
27+
import org.eclipse.lsp4j.WorkspaceFolder;
2528
import org.eclipse.swt.SWT;
2629
import org.eclipse.swt.layout.GridData;
2730
import org.eclipse.swt.layout.GridLayout;
@@ -38,6 +41,7 @@
3841
import org.osgi.service.event.EventHandler;
3942

4043
import com.microsoft.copilot.eclipse.core.Constants;
44+
import com.microsoft.copilot.eclipse.core.Constants.CustomInstructionsChatLoadScope;
4145
import com.microsoft.copilot.eclipse.core.CopilotCore;
4246
import com.microsoft.copilot.eclipse.core.chat.BuiltInChatMode;
4347
import com.microsoft.copilot.eclipse.core.chat.BuiltInChatModeManager;
@@ -86,6 +90,7 @@
8690
import com.microsoft.copilot.eclipse.ui.chat.viewers.LoadingViewer;
8791
import com.microsoft.copilot.eclipse.ui.chat.viewers.NoSubscriptionViewer;
8892
import com.microsoft.copilot.eclipse.ui.swt.CssConstants;
93+
import com.microsoft.copilot.eclipse.ui.utils.ResourceUtils;
8994
import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;
9095

9196
/**
@@ -957,7 +962,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl
957962

958963
CompletableFuture<ChatTurnResult> addConversationFuture = ls.addConversationTurn(workDoneToken, conversationId,
959964
processedMessage, references, currentFile, currentSelection, activeModel, chatModeName, customChatModeId,
960-
currentTodos, agentSlug, agentJobWorkspaceFolder);
965+
currentTodos, agentSlug, agentJobWorkspaceFolder, deriveWorkspaceFolders(currentFile, references));
961966
conversationFutures.add(addConversationFuture);
962967

963968
addConversationFuture.thenAccept(result -> {
@@ -1002,16 +1007,18 @@ private void onSendInternal(String workDoneToken, String message, String agentSl
10021007
chatModeName, customChatModeId, currentFile, references);
10031008
}
10041009

1010+
List<WorkspaceFolder> workspaceFolders = deriveWorkspaceFolders(currentFile, references);
10051011
CompletableFuture<ChatCreateResult> createConversationFuture = null;
10061012
if (StringUtils.isBlank(agentSlug)) {
10071013
createConversationFuture = ls.createConversation(workDoneToken, processedMessage, references, currentFile,
1008-
currentSelection, turns, activeModel, chatModeName, customChatModeId, todosToRestore, null, null);
1014+
currentSelection, turns, activeModel, chatModeName, customChatModeId, todosToRestore, null, null,
1015+
workspaceFolders);
10091016
} else {
10101017
// For conversations sending to agents, include agentSlug and specify the target agentJobWorkspaceFolder
10111018
// Don't send todo list for agent jobs - agents manage their own todo state independently
10121019
createConversationFuture = ls.createConversation(workDoneToken, processedMessage, references, currentFile,
10131020
currentSelection, turns, activeModel, chatModeName, customChatModeId, null, agentSlug,
1014-
agentJobWorkspaceFolder);
1021+
agentJobWorkspaceFolder, workspaceFolders);
10151022
}
10161023
conversationFutures.add(createConversationFuture);
10171024

@@ -1051,6 +1058,27 @@ private void onSendInternal(String workDoneToken, String message, String agentSl
10511058
}
10521059
}
10531060

1061+
List<WorkspaceFolder> deriveWorkspaceFolders(IFile currentFile, List<IResource> references) {
1062+
String chatInstrScope = CopilotUi.getPlugin().getPreferenceStore().getString(
1063+
Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE);
1064+
CustomInstructionsChatLoadScope scope;
1065+
try {
1066+
scope = CustomInstructionsChatLoadScope.fromValue(chatInstrScope);
1067+
} catch (IllegalArgumentException e) {
1068+
CopilotCore.LOGGER.error(
1069+
"Failed parsing custom instructions load scope for chat preference, using default value", e);
1070+
scope = CustomInstructionsChatLoadScope.DEFAULT_VALUE;
1071+
}
1072+
return switch (scope) {
1073+
// take all projects from Eclipse workspace
1074+
case ALL_PROJECTS -> LSPEclipseUtils.getWorkspaceFolders();
1075+
1076+
// take only projects from selected files/folders
1077+
case REFERENCED_PROJECTS -> ResourceUtils.deriveWorkspaceFoldersFrom(
1078+
Stream.concat(references.stream(), Stream.of(currentFile)).toList());
1079+
};
1080+
}
1081+
10541082
/**
10551083
* Align with @Workspace of vscode, because we are actually indexing the whole workspace, not a single project.
10561084
* (@Project is only for IntelliJ.)

0 commit comments

Comments
 (0)