Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

package com.microsoft.copilot.eclipse.core.lsp;

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

import java.util.List;
import java.util.concurrent.CompletableFuture;

import org.eclipse.lsp4e.LanguageServerWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class CopilotLanguageServerConnectionTests {

@Mock
private LanguageServerWrapper languageServerWrapper;

private CopilotLanguageServerConnection connection;

@BeforeEach
void setUp() {
connection = new CopilotLanguageServerConnection(languageServerWrapper);
}

@Test
void testCreateConversation_noActiveModel_failsWithoutRequest() {
CompletableFuture<?> result = connection.createConversation("token", "hello", List.of(), null, null, null, null,
null, null, "Ask", null, null, null, null, null, null, null);

assertTrue(result.isCompletedExceptionally());
verify(languageServerWrapper, never()).execute(any());
}

@Test
void testAddConversationTurn_noActiveModel_failsWithoutRequest() {
CompletableFuture<?> result = connection.addConversationTurn("token", "conversation", "hello", List.of(), null,
null, null, null, null, "Ask", null, null, null, null, null);

assertTrue(result.isCompletedExceptionally());
verify(languageServerWrapper, never()).execute(any());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok
String reasoningEffort, Integer contextSize, String chatModeName, String customChatModeId, List<TodoItem> todos,
String agentSlug, String agentJobWorkspaceFolder, String conversationId, String restoreToTurnId,
List<WorkspaceFolder> workspaceFolders) {
if (activeModel == null) {
return noActiveModelFailure();
}

boolean supportVision = activeModel.getCapabilities().supports().vision();
Either<String, List<ChatCompletionContentPart>> messageWithImages = ChatMessageUtils
Expand Down Expand Up @@ -331,6 +334,9 @@ public CompletableFuture<ChatTurnResult> addConversationTurn(String workDoneToke
String reasoningEffort, Integer contextSize, String chatModeName, String customChatModeId,
List<TodoItem> todoList, String agentSlug, String agentJobWorkspaceFolder,
List<WorkspaceFolder> workspaceFolders) {
if (activeModel == null) {
return noActiveModelFailure();
}

boolean supportVision = activeModel.getCapabilities().supports().vision();
Either<String, List<ChatCompletionContentPart>> messageWithImages = ChatMessageUtils
Expand Down Expand Up @@ -732,6 +738,11 @@ public void stop() {
this.languageServerWrapper.stop();
}

private static <T> CompletableFuture<T> noActiveModelFailure() {
// The language server rejects chat requests without a model.
return CompletableFuture.failedFuture(new IllegalStateException("No active chat model available"));
}

private String getModelName(CopilotModel activeModel) {
return activeModel == null ? null
: activeModel.isChatFallback() ? activeModel.getId() : activeModel.getModelFamily();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

package com.microsoft.copilot.eclipse.ui.chat;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mockStatic;

import java.lang.reflect.Field;
import java.util.concurrent.atomic.AtomicReference;

import org.eclipse.swt.SWT;
import org.eclipse.swt.widgets.Button;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;

import com.microsoft.copilot.eclipse.core.CopilotCore;
import com.microsoft.copilot.eclipse.core.FeatureFlags;
import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode;
import com.microsoft.copilot.eclipse.ui.CopilotUi;
import com.microsoft.copilot.eclipse.ui.chat.contextwindow.ContextWindowService;
import com.microsoft.copilot.eclipse.ui.chat.services.AgentToolService;
import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService;
import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager;
import com.microsoft.copilot.eclipse.ui.chat.services.McpConfigService;
import com.microsoft.copilot.eclipse.ui.chat.services.ModelService;
import com.microsoft.copilot.eclipse.ui.chat.services.ReferencedFileService;
import com.microsoft.copilot.eclipse.ui.chat.services.UserPreferenceService;
import com.microsoft.copilot.eclipse.ui.chat.tools.JavaDebuggerToolAdapter;
import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;

@ExtendWith(MockitoExtension.class)
class ActionBarTest {

@Mock
private UserPreferenceService mockUserPreferenceService;
@Mock
private ModelService mockModelService;
@Mock
private ChatServiceManager mockChatServiceManager;
@Mock
private ReferencedFileService mockReferencedFileService;
@Mock
private CopilotUi mockCopilotUi;
@Mock
private McpConfigService mockMcpConfigService;
@Mock
private AgentToolService mockAgentToolService;
@Mock
private ChatFontService mockChatFontService;
@Mock
private ContextWindowService mockContextWindowService;

private Shell shell;
private ActionBar actionBar;
private MockedStatic<CopilotUi> mockedCopilotUi;
private FeatureFlags featureFlags;
private boolean previewFeaturesEnabled;

@BeforeEach
void setUp() {
featureFlags = CopilotCore.getPlugin().getFeatureFlags();
previewFeaturesEnabled = featureFlags.isClientPreviewFeatureEnabled();
featureFlags.setClientPreviewFeatureEnabled(true);

mockedCopilotUi = mockStatic(CopilotUi.class);
mockedCopilotUi.when(CopilotUi::getPlugin).thenReturn(mockCopilotUi);
lenient().when(mockCopilotUi.getChatServiceManager()).thenReturn(mockChatServiceManager);
lenient().when(mockChatServiceManager.getUserPreferenceService()).thenReturn(mockUserPreferenceService);
lenient().when(mockChatServiceManager.getModelService()).thenReturn(mockModelService);
lenient().when(mockChatServiceManager.getReferencedFileService()).thenReturn(mockReferencedFileService);
lenient().when(mockChatServiceManager.getMcpConfigService()).thenReturn(mockMcpConfigService);
lenient().when(mockChatServiceManager.getAgentToolService()).thenReturn(mockAgentToolService);
lenient().when(mockChatServiceManager.getChatFontService()).thenReturn(mockChatFontService);
lenient().when(mockChatServiceManager.getContextWindowService()).thenReturn(mockContextWindowService);
lenient().when(mockAgentToolService.getTool(JavaDebuggerToolAdapter.TOOL_NAME)).thenReturn(null);
lenient().when(mockUserPreferenceService.getActiveChatMode()).thenReturn(ChatMode.Ask);

runOnDisplayThread(() -> {
shell = new Shell(Display.getDefault());
actionBar = new ActionBar(shell, SWT.NONE, mockChatServiceManager);
});
}

@AfterEach
void tearDown() {
SwtUtils.invokeOnDisplayThread(() -> {
if (shell != null && !shell.isDisposed()) {
shell.dispose();
}
});
mockedCopilotUi.close();
featureFlags.setClientPreviewFeatureEnabled(previewFeaturesEnabled);
}

@Test
void testUpdateButtonsLayout_noModelWithText_sendButtonsDisabled() {
AtomicReference<Boolean> sendEnabled = new AtomicReference<>();
AtomicReference<Boolean> delegateEnabled = new AtomicReference<>();
runOnDisplayThread(() -> {
actionBar.setInputTextViewerContent("hello");

// Recreates the send buttons, e.g. on chat mode switch
actionBar.updateButtonsLayout();
sendEnabled.set(getButton("btnMsgToggle").getEnabled());
delegateEnabled.set(getButton("sendToJobButton").getEnabled());
});

assertFalse(sendEnabled.get(), "Send must be disabled without a model");
assertFalse(delegateEnabled.get(), "Delegate must be disabled without a model");
}

// The workbench's syncExec swallows exceptions, so rethrow them on the test thread
private static void runOnDisplayThread(Runnable runnable) {
AtomicReference<Throwable> error = new AtomicReference<>();
SwtUtils.invokeOnDisplayThread(() -> {
try {
runnable.run();
} catch (Throwable t) {
error.set(t);
}
});
assertNull(error.get(), () -> "Unexpected error on display thread: " + error.get());
}

private Button getButton(String fieldName) {
try {
Field field = ActionBar.class.getDeclaredField(fieldName);
field.setAccessible(true);
return (Button) field.get(actionBar);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Failed to read " + fieldName, e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.io.IOException;
Expand Down Expand Up @@ -42,6 +46,7 @@
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope;
import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokListModelResponse;
import com.microsoft.copilot.eclipse.ui.chat.ActionBar;

@ExtendWith(MockitoExtension.class)
class ModelServiceTests {
Expand Down Expand Up @@ -231,6 +236,23 @@ void testSetActiveModel_AlreadyActiveModelDoesNotPersistPreference() throws Inte
assertFalse(Files.exists(getPreferenceFile()));
}

@Test
void testBindActionBarForModelAvailability_modelsPending_enablesSendOnlyAfterModelSet() {
CopilotModel defaultModel = createModel("gpt-4o", "GPT-4o", true);
CompletableFuture<CopilotModel[]> copilotModels = new CompletableFuture<>();
when(lsConnection.listModels()).thenReturn(copilotModels);
ActionBar actionBar = mock(ActionBar.class);

modelService = new ModelService(lsConnection, authStatusManager);
modelService.bindActionBarForModelAvailability(actionBar);

verify(actionBar, timeout(5000)).setModelAvailable(false);
verify(actionBar, never()).setModelAvailable(true);

copilotModels.complete(new CopilotModel[] { defaultModel });
verify(actionBar, timeout(5000)).setModelAvailable(true);
}

private static CopilotModel createModel(String id, String name, boolean isChatDefault) {
CopilotModel model = new CopilotModel();
model.setId(id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public class ActionBar extends Composite implements NewConversationListener {
private Image sendImage;
private Image sendDisabledImage;
private boolean isSendButton = true;
private boolean modelAvailable;
private LinkedHashSet<MessageListener> messageListeners = new LinkedHashSet<>();
private Button mcpToolButton;
private Image mcpToolImage;
Expand Down Expand Up @@ -206,6 +207,7 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana

ModelService modelService = chatServiceManager.getModelService();
modelService.bindActionBarForSupportVisionChange(this);
modelService.bindActionBarForModelAvailability(this);

ChatInputTextViewer tv = new ChatInputTextViewer(borderedActionBar, chatServiceManager);
tv.setEditable(true);
Expand All @@ -229,15 +231,11 @@ public void textChanged(TextEvent event) {
if (!isSendButton) {
return;
}
if (tv.getDocument().get().equals(StringUtils.EMPTY)) {
updateButtonState(SendOrCancelButtonStates.SEND_DISABLED);
} else {
updateButtonState(SendOrCancelButtonStates.SEND_ENABLED);
}
refreshSendButtonState();
}
});
tv.setSendMessageHandler((message) -> {
if (isSendButton) {
if (isSendButton && modelAvailable) {
handleSendMessage();
}
});
Expand Down Expand Up @@ -424,9 +422,9 @@ public void updateButtonsLayout() {

this.sendToJobButton = UiUtils.createIconButton(this.bottomRightButtonsComposite, SWT.PUSH | SWT.FLAT);

boolean hasText = !StringUtils.isBlank(this.inputTextViewer.getContent());
this.sendToJobButton.setEnabled(hasText);
this.sendToJobButton.setImage(hasText ? sendToJobImage : sendToJobDisabledImage);
boolean isEnabled = canSend();
this.sendToJobButton.setEnabled(isEnabled);
this.sendToJobButton.setImage(isEnabled ? sendToJobImage : sendToJobDisabledImage);
this.sendToJobButton.setToolTipText(Messages.chat_actionBar_sendToJobButton_Tooltip);
AccessibilityUtils.addAccessibilityNameForUiComponent(this.sendToJobButton,
Messages.chat_actionBar_sendToJobButton_Tooltip);
Expand All @@ -448,7 +446,7 @@ public void widgetSelected(SelectionEvent e) {
this.sendImage = CopilotImages.getImage(CopilotImages.IMG_CHAT_SEND);
this.sendDisabledImage = CopilotImages.getImage(CopilotImages.IMG_CHAT_SEND_DISABLED);
this.btnMsgToggle = UiUtils.createIconButton(bottomRightButtonsComposite, SWT.PUSH | SWT.FLAT);
boolean isEnabled = !StringUtils.isBlank(this.inputTextViewer.getContent());
boolean isEnabled = canSend();
this.btnMsgToggle.setEnabled(isEnabled);
this.btnMsgToggle.setImage(isEnabled ? this.sendImage : this.sendDisabledImage);
this.btnMsgToggle.setToolTipText(Messages.chat_actionBar_sendButton_Tooltip);
Expand Down Expand Up @@ -713,12 +711,38 @@ public void onNewConversation() {
* Handles the cancel message event.
*/
public void resetSendButton() {
if (this.inputTextViewer.getContent().isEmpty()) {
updateButtonState(SendOrCancelButtonStates.SEND_DISABLED);
} else {
refreshSendButtonState();
this.chatServiceManager.getFileToolService().setWorkingSetBarButtonStatus(true);
}

/**
* Updates whether a chat model is available. Sending is disabled while no model is available.
*
* @param available true if an active model is set
*/
public void setModelAvailable(boolean available) {
SwtUtils.invokeOnDisplayThreadAsync(() -> {
if (isDisposed()) {
return;
}
modelAvailable = available;
if (isSendButton) {
refreshSendButtonState();
}
refreshPlaceholder();
}, this);
}

private void refreshSendButtonState() {
if (canSend()) {
updateButtonState(SendOrCancelButtonStates.SEND_ENABLED);
} else {
updateButtonState(SendOrCancelButtonStates.SEND_DISABLED);
}
this.chatServiceManager.getFileToolService().setWorkingSetBarButtonStatus(true);
}

private boolean canSend() {
return modelAvailable && !StringUtils.isBlank(this.inputTextViewer.getContent());
}

/**
Expand Down Expand Up @@ -794,11 +818,7 @@ private void handleSendToJob() {
notifySendWithSlug(workDoneToken, message, UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG, selectedProjectPath);
} else {
// Dialog was cancelled, reset button state based on current input content
if (this.inputTextViewer.getContent().isEmpty()) {
updateButtonState(SendOrCancelButtonStates.SEND_DISABLED);
} else {
updateButtonState(SendOrCancelButtonStates.SEND_ENABLED);
}
refreshSendButtonState();
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,9 @@ private void handleSendMessage() {
}

private String getPlaceholderText() {
if (!chatServiceManager.getModelService().hasActiveModel()) {
return Messages.chat_actionBar_noModel_placeholder;
}
switch (userPreferenceService.getActiveChatMode()) {
case Agent:
// Get the active mode name or ID from observable (not from disk to avoid stale data)
Expand Down
Loading
Loading