Skip to content
Draft
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
Expand Up @@ -8,6 +8,8 @@
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
Expand Down Expand Up @@ -35,6 +37,31 @@ void setUp() {
authStatusManager = new AuthStatusManager(mockConnection);
}

@Test
void testCheckStatus_AccountSwitchWithSameStatus_NotifiesOncePerAccount() {
when(mockConnection.checkQuota()).thenReturn(CompletableFuture.completedFuture(CheckQuotaResult.empty()));
List<String> accounts = new ArrayList<>();
List<CopilotStatusResult> notifications = new ArrayList<>();
authStatusManager.addCopilotAuthStatusListener(status -> accounts.add(status.getUser()));
authStatusManager.addCopilotAuthStatusListener(notifications::add);
CopilotStatusResult alice = new CopilotStatusResult();
alice.setUser("alice");
alice.setStatus(CopilotStatusResult.OK);
CopilotStatusResult bob = new CopilotStatusResult();
bob.setUser("bob");
bob.setStatus(CopilotStatusResult.OK);
when(mockConnection.checkStatus(false))
.thenReturn(CompletableFuture.completedFuture(alice), CompletableFuture.completedFuture(bob));

authStatusManager.checkStatus();
authStatusManager.checkStatus();
authStatusManager.checkStatus();

assertEquals(List.of("alice", "bob"), accounts);
assertEquals("alice", notifications.get(0).getUser(), "Queued notifications retain their original identity");
assertEquals("bob", authStatusManager.getUserName());
}

@Test
void testSignInInitiate() throws InterruptedException, ExecutionException {
SignInInitiateResult mockResult = mock(SignInInitiateResult.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,32 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

import org.eclipse.core.runtime.preferences.IEclipsePreferences;
import org.eclipse.core.runtime.preferences.InstanceScope;
import org.eclipse.e4.core.contexts.EclipseContextFactory;
import org.eclipse.e4.core.contexts.IEclipseContext;
import org.eclipse.e4.core.services.events.IEventBroker;
import org.eclipse.lsp4j.jsonrpc.messages.NotificationMessage;
import org.eclipse.lsp4j.services.LanguageServer;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.osgi.framework.BundleContext;

import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants;
import com.microsoft.copilot.eclipse.core.lsp.protocol.InitializationOptions;

class LsStreamConnectionProviderTests {
Expand All @@ -22,6 +40,36 @@ class LsStreamConnectionProviderTests {
private static final String UI_PREFERENCE_NODE = "com.microsoft.copilot.eclipse.ui";
private static final long TERMINATION_GRACE_MS = 2000L;

@Test
void testHandleMessage_Initialized_EmitsOncePerProviderAndIgnoresStoppedProviders() {
IEventBroker broker = mock(IEventBroker.class);
IEclipseContext context = mock(IEclipseContext.class);
when(context.get(IEventBroker.class)).thenReturn(broker);
when(broker.post(eq(CopilotEventConstants.TOPIC_LANGUAGE_SERVER_INITIALIZED), anyLong())).thenReturn(true);
try (MockedStatic<EclipseContextFactory> contexts = mockStatic(EclipseContextFactory.class)) {
contexts.when(() -> EclipseContextFactory.getServiceContext(any(BundleContext.class))).thenReturn(context);
LanguageServer server = mock(LanguageServer.class);
NotificationMessage message = new NotificationMessage();
LsStreamConnectionProvider first = new LsStreamConnectionProvider();
message.setMethod("didChangeStatus");
first.handleMessage(message, server, null);
message.setMethod("initialized");
first.handleMessage(message, server, null);
first.handleMessage(message, server, null);
first.stop();
first.handleMessage(message, server, null);
LsStreamConnectionProvider stopped = new LsStreamConnectionProvider();
stopped.stop();
stopped.handleMessage(message, server, null);
new LsStreamConnectionProvider().handleMessage(message, server, null);

ArgumentCaptor<Long> incarnations = ArgumentCaptor.forClass(Long.class);
verify(broker, times(2)).post(eq(CopilotEventConstants.TOPIC_LANGUAGE_SERVER_INITIALIZED),
incarnations.capture());
assertTrue(incarnations.getAllValues().get(1) > incarnations.getAllValues().get(0));
}
}

@Test
void testInitializationOptions() {
LsStreamConnectionProvider provider = new LsStreamConnectionProvider();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public class AuthStatusManager {
private CopilotLanguageServerConnection connection;
private ConcurrentLinkedQueue<CopilotAuthStatusListener> copilotAuthStatusListeners;
private CopilotStatusResult copilotStatusResult;
private String notifiedUser;
private CheckQuotaResult checkQuotaResult;
private IEventBroker eventBroker;

Expand Down Expand Up @@ -99,9 +100,14 @@ public CopilotStatusResult signOut() throws InterruptedException, ExecutionExcep
* Set the CopilotStatusResult string to the given status and notify the listeners.
*/
public CopilotStatusResult setCopilotStatus(String newCopilotStatusResult) {
if (!Objects.equals(this.copilotStatusResult.getStatus(), newCopilotStatusResult)) {
if (!Objects.equals(this.copilotStatusResult.getStatus(), newCopilotStatusResult)
|| !Objects.equals(notifiedUser, this.copilotStatusResult.getUser())) {
this.copilotStatusResult.setStatus(newCopilotStatusResult);
onDidCopilotStatusChange(this.copilotStatusResult);
notifiedUser = this.copilotStatusResult.getUser();
CopilotStatusResult notification = new CopilotStatusResult();
notification.setStatus(newCopilotStatusResult);
notification.setUser(notifiedUser);
onDidCopilotStatusChange(notification);
}
return this.copilotStatusResult;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ public class CopilotEventConstants {
*/
public static final String TOPIC_AUTH_STATUS_CHANGED = TOPIC_AUTH + "STATUS_CHANGED";

/**
* Successful LSP initialization; data is a monotonically increasing connection incarnation.
*/
public static final String TOPIC_LANGUAGE_SERVER_INITIALIZED = TOPIC_BASE + "LANGUAGE_SERVER_INITIALIZED";

/**
* Event when MCP tools changed.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,25 @@
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;

import org.apache.commons.lang3.StringUtils;
import org.eclipse.core.runtime.FileLocator;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.URIUtil;
import org.eclipse.e4.core.contexts.EclipseContextFactory;
import org.eclipse.e4.core.services.events.IEventBroker;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.lsp4e.server.ProcessStreamConnectionProvider;
import org.eclipse.lsp4j.jsonrpc.messages.Message;
import org.eclipse.lsp4j.jsonrpc.messages.NotificationMessage;
import org.eclipse.lsp4j.services.LanguageServer;
import org.osgi.framework.Bundle;
import org.osgi.framework.FrameworkUtil;

import com.microsoft.copilot.eclipse.core.CopilotCore;
import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotCapabilities;
import com.microsoft.copilot.eclipse.core.lsp.protocol.InitializationOptions;
import com.microsoft.copilot.eclipse.core.lsp.protocol.NameAndVersion;
Expand All @@ -46,6 +55,34 @@ public class LsStreamConnectionProvider extends ProcessStreamConnectionProvider

public static final String EDITOR_NAME = "Eclipse";
public static final String EDITOR_PLUGIN_NAME = "copilot-eclipse";
private static final AtomicLong CONNECTION_INCARNATION = new AtomicLong();
private final AtomicBoolean initialized = new AtomicBoolean();

@Override
public void handleMessage(Message message, LanguageServer languageServer, @Nullable URI rootUri) {
super.handleMessage(message, languageServer, rootUri);
// LSP4E invokes this hook after sending initialized, only after initialize has succeeded.
// It creates a fresh provider on restart; status notifications are not connection recovery.
if (message instanceof NotificationMessage notification && "initialized".equals(notification.getMethod())
&& initialized.compareAndSet(false, true)) {
try {
IEventBroker broker = EclipseContextFactory
.getServiceContext(FrameworkUtil.getBundle(getClass()).getBundleContext()).get(IEventBroker.class);
if (broker == null || !broker.post(CopilotEventConstants.TOPIC_LANGUAGE_SERVER_INITIALIZED,
CONNECTION_INCARNATION.incrementAndGet())) {
CopilotCore.LOGGER.error(new IllegalStateException("Cannot publish language server initialization"));
}
} catch (RuntimeException exception) {
CopilotCore.LOGGER.error("Cannot publish language server initialization", exception);
}
}
}

@Override
public void stop() {
initialized.set(true);
super.stop();
}

@Override
public Object getInitializationOptions(@Nullable URI rootUri) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ Bundle-Vendor: GitHub Copilot
Bundle-RequiredExecutionEnvironment: JavaSE-17
Fragment-Host: com.microsoft.copilot.eclipse.ui
Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.test
Import-Package: org.objenesis;version="[3.4.0,4.0.0)",
Import-Package: org.eclipse.lsp4j.jsonrpc.messages,
org.objenesis;version="[3.4.0,4.0.0)",
org.opentest4j
Require-Bundle: junit-jupiter-api;bundle-version="5.10.1",
junit-jupiter-params;bundle-version="5.10.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
import java.util.concurrent.atomic.AtomicLong;

import org.eclipse.core.databinding.observable.Realm;
import org.eclipse.e4.core.services.events.IEventBroker;
import org.eclipse.ui.PlatformUI;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand All @@ -48,6 +50,7 @@
import com.microsoft.copilot.eclipse.core.AuthStatusManager;
import com.microsoft.copilot.eclipse.core.CopilotAuthStatusListener;
import com.microsoft.copilot.eclipse.core.chat.UserPreference;
import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants;
import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection;
import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatPersistence;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult;
Expand Down Expand Up @@ -89,6 +92,109 @@ void tearDown() {
realm.drain();
}

@Test
void testRecovery_FailureAndDuplicateSignals_RetriesOncePerConnectionAndAllowsManualRetry() throws Exception {
storage.initialize();
drainWork();
response.completeExceptionally(new IOException("offline"));
realm.drain();
response = new CompletableFuture<>();
recovered(1);
recovered(1);
storage.retry();
drainWork();
assertEquals(State.LOADING, storage.getState());
verify(connection, times(2)).persistence();
response.completeExceptionally(new IOException("still offline"));
realm.drain();
recovered(1);
authListener().onDidCopilotStatusChange(new CopilotStatusResult());
drainWork();
assertEquals(State.FAILED, storage.getState());
verify(connection, times(2)).persistence();

response = new CompletableFuture<>();
when(files.read(any(Path.class))).thenReturn("{\"chatModel\":\"restored\"}");
storage.retry();
recovered(2);
recovered(1);
drainWork();
response.complete(persistence());
drainWork();
realm.drain();
assertEquals("restored", storage.getReadyPreferences().getChatModel());
verify(connection, times(3)).persistence();
}

@Test
void testRecovery_SameAccountDirtyChoicesAndSaveFailure_RemainUnchanged() throws Exception {
when(files.read(any(Path.class))).thenReturn("{}");
load();
Mockito.doThrow(new IOException("read-only")).when(files).write(any(), any());
storage.update(preference -> preference.setChatModel("unsaved"));
drainWork();
realm.drain();
recovered(1);
recovered(2);
authListener().onDidCopilotStatusChange(new CopilotStatusResult());
drainWork();
realm.drain();

assertEquals("unsaved", storage.getReadyPreferences().getChatModel());
assertTrue(storage.isDirty());
assertEquals(PreferenceStorage.SaveState.FAILED, storage.getSaveStatus().getValue());
verify(connection).persistence();
verify(files).write(any(), any());
}

@Test
void testRecovery_SignedOutAndDisposed_DoesNotLoadOrPublish() {
when(auth.isSignedIn()).thenReturn(false);
authListener().onDidCopilotStatusChange(new CopilotStatusResult());
recovered(1);
drainWork();
realm.drain();
assertEquals(State.UNAVAILABLE, storage.getState());
assertNull(storage.getReadyPreferences());
storage.dispose();
when(auth.isSignedIn()).thenReturn(true);
authListener().onDidCopilotStatusChange(new CopilotStatusResult());
recovered(2);
drainWork();
realm.drain();

assertEquals(State.DISPOSED, storage.getState());
verify(connection, never()).persistence();
verifyNoInteractions(files);
}

@Test
void testRecovery_TimedOutOldRpc_CannotReplaceSuccessfulRecovery() throws Exception {
response = new UncancellableFuture();
CompletableFuture<ChatPersistence> old = response;
storage.initialize();
drainWork();
expire();
realm.drain();
response = new CompletableFuture<>();
when(files.read(any(Path.class))).thenReturn("{\"chatModel\":\"new\"}");
recovered(1);
drainWork();
response.complete(persistence());
drainWork();
realm.drain();
old.complete(persistence());
drainWork();
realm.drain();
assertEquals("new", storage.getReadyPreferences().getChatModel());
verify(files).read(any());
}

private void recovered(long incarnation) {
PlatformUI.getWorkbench().getService(IEventBroker.class)
.send(CopilotEventConstants.TOPIC_LANGUAGE_SERVER_INITIALIZED, incarnation);
}

@Test
void testUpdate_RapidChanges_AreImmediateAndCoalesceBeforeWriting() throws Exception {
when(files.read(any(Path.class))).thenReturn("{}");
Expand Down Expand Up @@ -202,6 +308,8 @@ void testUpdate_AccountChangesDuringOldWrite_OldFailureCannotAffectNewSave() thr
assertEquals(Path.of(persistence().getPath(), "alice", "pref.json"), invocation.getArgument(0));
when(auth.getUserName()).thenReturn("bob");
response = new CompletableFuture<>();
authListener().onDidCopilotStatusChange(new CopilotStatusResult());
recovered(1);
load();
storage.update(preference -> preference.setChatModel("bob-model"));
throw new IOException("old account failure");
Expand Down Expand Up @@ -667,7 +775,7 @@ void testAccountChange_LateOldRpc_DoesNotAffectNewAccount() throws Exception {
when(auth.getUserName()).thenReturn("bob");
authListener().onDidCopilotStatusChange(new CopilotStatusResult());
realm.drain();
assertEquals(State.UNAVAILABLE, storage.getState());
assertEquals(State.LOADING, storage.getState());
verify(connection).persistence();

response = new CompletableFuture<>();
Expand All @@ -687,7 +795,7 @@ void testAccountChange_LateOldRpc_DoesNotAffectNewAccount() throws Exception {
}

@Test
void testAuthNotification_SignOut_InvalidatesWithoutAutomaticRetry() throws Exception {
void testAuthNotification_SignOutThenLogin_LoadsOnceWithoutManualRetry() throws Exception {
when(files.read(any(Path.class))).thenReturn("{}");
load();
when(auth.isSignedIn()).thenReturn(false);
Expand All @@ -696,12 +804,18 @@ void testAuthNotification_SignOut_InvalidatesWithoutAutomaticRetry() throws Exce
realm.drain();
assertEquals(State.UNAVAILABLE, storage.getReadiness().getValue());

response = new CompletableFuture<>();
when(auth.isSignedIn()).thenReturn(true);
authListener().onDidCopilotStatusChange(new CopilotStatusResult());
authListener().onDidCopilotStatusChange(new CopilotStatusResult());
drainWork();
realm.drain();
verify(connection).persistence();
assertEquals(State.UNAVAILABLE, storage.getState());
verify(connection, times(2)).persistence();
assertEquals(State.LOADING, storage.getState());
response.complete(persistence());
drainWork();
realm.drain();
assertEquals(State.READY, storage.getState());
}

@Test
Expand Down
Loading
Loading