diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorageTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorageTest.java index de41c3e8..1de18b67 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorageTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorageTest.java @@ -114,6 +114,7 @@ void testInitialize_SavedPreferences_RestoresOnlyInRealm() throws Exception { assertEquals("high", restored.getReasoningEffort("model-a")); assertEquals(128000, restored.getContextWindow("model-a")); assertSame(restored, storage.getReadyPreferences()); + assertEquals(PreferenceStorage.InitializationResult.NONE, storage.getInitializationResult().getValue()); } @Test @@ -125,29 +126,60 @@ void testInitialize_MissingFile_UsesFirstRunDefaultsWithoutWriting() throws Exce assertNotNull(storage.getReadyPreferences()); assertNull(storage.getReadyPreferences().getChatModel()); assertTrue(storage.getReadyPreferences().getReasoningEffortSnapshot().isEmpty()); + assertEquals(PreferenceStorage.InitializationResult.NONE, storage.getInitializationResult().getValue()); verify(files, never()).write(any(), any()); } @Test - void testInitialize_UnreadableFile_FailsAndDoesNotOverwrite() throws Exception { + void testDismissal_PersistsUntilAccountLifecycleChanges() throws Exception { when(files.read(any(Path.class))).thenThrow(new AccessDeniedException("pref.json")); load(); + storage.dismissInitializationResult(); + realm.drain(); + + assertEquals(State.READY, storage.getState()); + assertEquals(PreferenceStorage.InitializationResult.RECOVERED_DISMISSED, + storage.getInitializationResult().getValue()); + authListener().onDidCopilotStatusChange(new CopilotStatusResult()); + realm.drain(); + assertEquals(PreferenceStorage.InitializationResult.RECOVERED_DISMISSED, + storage.getInitializationResult().getValue()); + + when(auth.getUserName()).thenReturn("bob"); + assertEquals(State.UNAVAILABLE, storage.getState()); + realm.drain(); + assertEquals(PreferenceStorage.InitializationResult.NONE, storage.getInitializationResult().getValue()); - assertFailedWithoutWrites(); + storage.initialize(); + drainWork(); + realm.drain(); + assertEquals(State.READY, storage.getState()); + assertEquals(PreferenceStorage.InitializationResult.RECOVERED, storage.getInitializationResult().getValue()); + } + + @Test + void testInitialize_UnreadableFile_RecoversWithoutOverwriting() throws Exception { + when(files.read(any(Path.class))).thenThrow(new AccessDeniedException("pref.json")); + load(); + + assertRecoveredWithoutWrites(); + storage.getReadyPreferences().setChatModel("session-choice"); + storage.persist(storage.getReadyPreferences()); storage.initialize(); drainWork(); verify(connection).persistence(); + verify(files, never()).write(any(), any()); } @ParameterizedTest @NullAndEmptySource @ValueSource(strings = {" ", "null", "[]", "{", "{\"chatModel\":\"x\"} garbage", "{chatModel:'x'}", "{\"chatModel\":\"x\",}", "/*comment*/{}", "{\"chatModel\":{}}", "{\"contextWindowByModel\":false}"}) - void testInitialize_InvalidJson_FailsAndDoesNotOverwrite(String content) throws Exception { + void testInitialize_InvalidJson_RecoversWithoutOverwriting(String content) throws Exception { when(files.read(any(Path.class))).thenReturn(content); load(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); } @Test @@ -169,11 +201,11 @@ void testInitialize_NullModelMaps_NormalizesSafeDefaults() throws Exception { @ValueSource(strings = {"{\"chatModel\":\"line\nbreak\"}", "{\"chatModel\":\"tab\tcharacter\"}", "{\"chatModel\":\"escaped\\\nnewline\"}", "{\"chatModel\":\"single\\'quote\"}", "{\"skipGitHubJobConfirmDialog\":TRUE}", "{\"chatModel\":NULL}"}) - void testInitialize_LegacyGsonExtensions_FailWithoutOverwriting(String content) throws Exception { + void testInitialize_LegacyGsonExtensions_RecoversWithoutOverwriting(String content) throws Exception { when(files.read(any(Path.class))).thenReturn(content); load(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); } @Test @@ -203,12 +235,10 @@ void testInitialize_PendingRpc_LeavesUiPublicationAndGettersAvailable() { } @Test - void testInitialize_ConcurrentCallersAndRetry_CoalescesPendingAndReadyLoads() throws Exception { + void testInitialize_ConcurrentCallers_CoalescesPendingAndReadyLoads() throws Exception { when(files.read(any(Path.class))).thenReturn("{}"); storage.initialize(); storage.initialize(); - storage.retry(); - storage.retry(); drainWork(); response.complete(persistence()); drainWork(); @@ -216,7 +246,6 @@ void testInitialize_ConcurrentCallersAndRetry_CoalescesPendingAndReadyLoads() th UserPreference restored = storage.getReadyPreferences(); restored.setChatModel("session-choice"); storage.initialize(); - storage.retry(); drainWork(); verify(connection).persistence(); @@ -240,45 +269,35 @@ void testInitialize_ParallelCallers_ShareOnePendingRequest() throws Exception { } @Test - void testInitialize_RpcFailure_RequiresExplicitRetry() throws Exception { + void testInitialize_RpcFailure_RecoversToDefaultsWithoutRetry() throws Exception { storage.initialize(); drainWork(); response.completeExceptionally(new IOException("offline")); + drainWork(); realm.drain(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); storage.initialize(); drainWork(); verify(connection).persistence(); - - response = new CompletableFuture<>(); - when(files.read(any(Path.class))).thenReturn("{\"chatModel\":\"recovered\"}"); - storage.retry(); - storage.retry(); - drainWork(); - response.complete(persistence()); - drainWork(); - realm.drain(); - - assertEquals("recovered", storage.getReadyPreferences().getChatModel()); - verify(connection, times(2)).persistence(); + verify(connection, times(1)).persistence(); } @Test - void testInitialize_NullRpcResponse_Fails() throws Exception { + void testInitialize_NullRpcResponse_Recovers() throws Exception { storage.initialize(); drainWork(); response.complete(null); drainWork(); realm.drain(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); verify(files, never()).read(any()); } @ParameterizedTest @NullAndEmptySource @ValueSource(strings = {" ", "relative-path", "invalid\u0000path"}) - void testInitialize_InvalidPersistencePath_Fails(String path) throws Exception { + void testInitialize_InvalidPersistencePath_Recovers(String path) throws Exception { storage.initialize(); drainWork(); ChatPersistence invalid = new ChatPersistence(); @@ -287,40 +306,40 @@ void testInitialize_InvalidPersistencePath_Fails(String path) throws Exception { drainWork(); realm.drain(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); verify(files, never()).read(any()); } @Test - void testInitialize_RpcInvocationThrows_FailsWithoutReading() throws Exception { + void testInitialize_RpcInvocationThrows_RecoversWithoutReading() throws Exception { when(connection.persistence()).thenThrow(new IllegalStateException("unavailable")); storage.initialize(); drainWork(); realm.drain(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); verify(files, never()).read(any()); } @Test - void testInitialize_NullRpcFuture_FailsWithoutReading() throws Exception { + void testInitialize_NullRpcFuture_RecoversWithoutReading() throws Exception { when(connection.persistence()).thenReturn(null); storage.initialize(); drainWork(); realm.drain(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); verify(files, never()).read(any()); } @Test - void testDeadline_PendingRpc_FailsAndCancelsRequest() throws Exception { + void testDeadline_PendingRpc_RecoversAndCancelsRequest() throws Exception { storage.initialize(); drainWork(); expire(); realm.drain(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); assertTrue(response.isCancelled()); } @@ -331,7 +350,7 @@ void testDeadline_BeforeWorkerDispatch_DoesNotStartRpc() throws Exception { drainWork(); realm.drain(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); verify(connection, never()).persistence(); } @@ -345,7 +364,7 @@ void testDeadline_DelayedTimer_StillRejectsPublicationAfterDeadline() throws Exc clock.set(TimeUnit.SECONDS.toNanos(15)); realm.drain(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); } @Test @@ -356,27 +375,22 @@ void testDeadline_DuringRead_RejectsLateReadResult() throws Exception { }); load(); - assertFailedWithoutWrites(); + assertRecoveredWithoutWrites(); } @Test - void testRetry_LateReadFromTimedOutAttempt_DoesNotReplaceSuccessfulRetry() throws Exception { + void testDeadline_LateRead_DoesNotReplaceRecoveredDefaults() throws Exception { when(files.read(any(Path.class))).thenAnswer(invocation -> { - Mockito.doReturn("{\"chatModel\":\"new\"}").when(files).read(any(Path.class)); expire(); - response = new CompletableFuture<>(); - storage.retry(); - drainWork(); - response.complete(persistence()); - drainWork(); - realm.drain(); return "{\"chatModel\":\"old\"}"; }); load(); assertEquals(State.READY, storage.getState()); - assertEquals("new", storage.getReadyPreferences().getChatModel()); - verify(connection, times(2)).persistence(); + assertNull(storage.getReadyPreferences().getChatModel()); + assertEquals(PreferenceStorage.InitializationResult.RECOVERED, + storage.getInitializationResult().getValue()); + verify(connection, times(1)).persistence(); } @Test @@ -392,7 +406,7 @@ void testInitialize_DuringFileRead_GettersRemainAvailableToOtherThreads() throws } @Test - void testRetry_LateRpcFromTimedOutAttempt_CannotReplaceNewPreferences() throws Exception { + void testDeadline_LateRpc_CannotReplaceRecoveredDefaults() throws Exception { response = new UncancellableFuture(); CompletableFuture old = response; storage.initialize(); @@ -400,20 +414,13 @@ void testRetry_LateRpcFromTimedOutAttempt_CannotReplaceNewPreferences() throws E expire(); realm.drain(); - response = new CompletableFuture<>(); - when(files.read(any(Path.class))).thenReturn("{\"chatModel\":\"new\"}"); - storage.retry(); - drainWork(); - response.complete(persistence()); - drainWork(); - realm.drain(); old.complete(persistence()); drainWork(); realm.drain(); assertEquals(State.READY, storage.getState()); - assertEquals("new", storage.getReadyPreferences().getChatModel()); - verify(files).read(any()); + assertNull(storage.getReadyPreferences().getChatModel()); + verify(files, never()).read(any()); verify(files, never()).write(any(), any()); } @@ -421,7 +428,6 @@ void testRetry_LateRpcFromTimedOutAttempt_CannotReplaceNewPreferences() throws E void testInitialize_SignedOut_DoesNotReadOrResolvePath() { when(auth.isSignedIn()).thenReturn(false); storage.initialize(); - storage.retry(); drainWork(); realm.drain(); @@ -486,7 +492,7 @@ void testAccountChange_LateOldRpc_DoesNotAffectNewAccount() throws Exception { response = new CompletableFuture<>(); when(files.read(any(Path.class))).thenReturn("{\"chatModel\":\"bob-model\"}"); - storage.retry(); + storage.initialize(); drainWork(); response.complete(persistence()); drainWork(); @@ -542,7 +548,6 @@ void testDispose_PendingRpc_DetachesListenerAndRejectsLateResults() throws Excep storage.dispose(); storage.dispose(); storage.initialize(); - storage.retry(); response.complete(persistence()); drainWork(); realm.drain(); @@ -705,10 +710,12 @@ private void load() { realm.drain(); } - private void assertFailedWithoutWrites() throws IOException { - assertEquals(State.FAILED, storage.getState()); - assertEquals(State.FAILED, storage.getReadiness().getValue()); - assertNull(storage.getReadyPreferences()); + private void assertRecoveredWithoutWrites() throws IOException { + assertEquals(State.READY, storage.getState()); + assertEquals(State.READY, storage.getReadiness().getValue()); + assertNotNull(storage.getReadyPreferences()); + assertEquals(PreferenceStorage.InitializationResult.RECOVERED, + storage.getInitializationResult().getValue()); storage.persist(); verify(files, never()).write(any(), any()); } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceServiceTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceServiceTest.java index ffc8eb6e..9d88417e 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceServiceTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceServiceTest.java @@ -42,6 +42,7 @@ import org.eclipse.swt.widgets.Shell; import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Link; +import org.eclipse.swt.widgets.Button; import org.eclipse.ui.PlatformUI; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -124,7 +125,8 @@ void testFirstInitialization_PendingModeDiscovery_ProcessesSwtAndPublishesModesA createControls(); Display.getDefault().asyncExec(() -> { try { - uiAction.complete(!picker.getEnabled() && storage.getState() == PreferenceStorage.State.LOADING); + uiAction.complete(!picker.getEnabled() && storage.getState() == PreferenceStorage.State.LOADING + && !status.getVisible()); } catch (Throwable error) { uiAction.completeExceptionally(error); } @@ -394,27 +396,28 @@ void testInitialization_AgentPolicyDisabled_RestoresAskView() throws Exception { } @Test - void testRetry_FailedLoadKeepsPickerDisabledThenRestoresSavedChoice() throws Exception { - writePreferences("{\"chatModeName\":\"Ask\"}"); - startAuthenticated(CompletableFuture.failedFuture(new IllegalStateException("offline"))); - awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.FAILED); + void testInitialization_FailedRestoreUsesDefaultsAndDismissesWarning() throws Exception { + Path file = writePreferences("{"); + startAuthenticated(CompletableFuture.completedFuture(persistence())); + awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.READY + && Messages.preferenceRecovered.equals(statusMessage().getText())); runOnUi(() -> { - assertFalse(picker.getEnabled()); + assertTrue(picker.getEnabled()); assertTrue(status.getVisible()); - Label message = Arrays.stream(status.getChildren()).filter(Label.class::isInstance) - .map(Label.class::cast).findFirst().orElseThrow(); - assertEquals(Messages.preferenceLoadFailed, message.getText()); - service.setActiveChatMode("Agent"); - assertNull(service.getActiveModeNameOrId()); + assertEquals("Agent", service.getActiveModeNameOrId()); + Button dismiss = Arrays.stream(status.getChildren()).filter(Button.class::isInstance) + .map(Button.class::cast).findFirst().orElseThrow(); + dismiss.notifyListeners(SWT.Selection, new org.eclipse.swt.widgets.Event()); }); - when(connection.persistence()).thenReturn(CompletableFuture.completedFuture(persistence())); + awaitUi(() -> !status.getVisible()); + assertEquals(PreferenceStorage.State.READY, storage.getState()); runOnUi(() -> { - Link retry = Arrays.stream(status.getChildren()).filter(Link.class::isInstance) - .map(Link.class::cast).findFirst().orElseThrow(); - assertTrue(retry.getEnabled()); - retry.notifyListeners(SWT.Selection, new org.eclipse.swt.widgets.Event()); + service.setActiveChatMode("Ask"); + assertEquals("Ask", service.getActiveModeNameOrId()); + assertTrue(picker.getEnabled()); + assertFalse(status.getVisible()); }); - awaitUi(() -> picker.getEnabled() && "Ask".equals(service.getActiveModeNameOrId())); + assertEquals("{", Files.readString(file), "Recovered defaults and later changes must not overwrite bad data"); } @Test @@ -451,7 +454,7 @@ void testModeChange_AfterRestoration_UpdatesSharedStateAndPersists() throws Exce } @Test - void testInitialization_DeadlineExpires_ShowsFailureAndRetryWithoutEnablingPicker() throws Exception { + void testInitialization_DeadlineExpires_RecoversToDefaultsAndEnablesPicker() throws Exception { when(auth.isSignedIn()).thenReturn(true); when(auth.getUserName()).thenReturn("user"); CompletableFuture pending = new CompletableFuture<>(); @@ -482,13 +485,12 @@ public void write(Path path, String content) throws IOException { runOnUi(() -> assertFalse(picker.getEnabled())); clock.set(TimeUnit.SECONDS.toNanos(15)); deadline.get().run(); - awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.FAILED); + awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.READY); runOnUi(() -> { - assertFalse(picker.getEnabled()); + assertTrue(picker.getEnabled()); assertTrue(status.getVisible()); - Link retry = Arrays.stream(status.getChildren()).filter(Link.class::isInstance) - .map(Link.class::cast).findFirst().orElseThrow(); - assertTrue(retry.getEnabled()); + assertEquals(Messages.preferenceRecovered, statusMessage().getText()); + assertEquals("Agent", service.getActiveModeNameOrId()); }); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java index 9e911649..fc219436 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java @@ -21,10 +21,10 @@ public final class Messages extends NLS { public static String chat_warnWidget_defaultErrorMsg; public static String chat_warnWidget_byokQuotaUsageMessage; public static String configureModes; - public static String preferenceLoading; - public static String preferenceLoadFailed; - public static String preferenceUnavailable; - public static String preferenceRetry; + public static String preferenceRecovered; + public static String preferenceRecoveryFailed; + public static String preferenceDismiss; + public static String modeDiscoveryRetry; public static String modeDiscoveryLoading; public static String modeDiscoveryFailed; public static String agentMessageWidget_openInBrowserButton; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/PreferenceStatus.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/PreferenceStatus.java index 7e276642..a71f2bbb 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/PreferenceStatus.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/PreferenceStatus.java @@ -8,21 +8,23 @@ import org.eclipse.swt.SWT; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Link; import com.microsoft.copilot.eclipse.ui.chat.services.PreferenceStorage; +import com.microsoft.copilot.eclipse.ui.chat.services.PreferenceStorage.InitializationResult; import com.microsoft.copilot.eclipse.ui.chat.services.PreferenceStorage.State; import com.microsoft.copilot.eclipse.ui.chat.services.UserPreferenceService; import com.microsoft.copilot.eclipse.ui.chat.services.UserPreferenceService.ModeDiscoveryState; /** - * Inline preference-loading status, independent of model and conversation loading. + * Inline preference initialization results and independent mode-discovery status. */ public class PreferenceStatus extends Composite { /** - * Creates a status message and an explicit retry action. + * Creates a preference result message. * * @param parent parent control * @param storage shared chat preference storage @@ -32,7 +34,7 @@ public PreferenceStatus(Composite parent, PreferenceStorage storage) { } /** - * Creates status and retry controls for preference loading and independent mode discovery. + * Creates result and retry controls for preference initialization and independent mode discovery. * * @param parent parent control * @param storage shared chat preference storage @@ -40,27 +42,30 @@ public PreferenceStatus(Composite parent, PreferenceStorage storage) { */ public PreferenceStatus(Composite parent, PreferenceStorage storage, UserPreferenceService preferences) { super(parent, SWT.NONE); - setLayout(new GridLayout(2, false)); + setLayout(new GridLayout(3, false)); GridData data = new GridData(SWT.FILL, SWT.CENTER, true, false); setLayoutData(data); Label message = new Label(this, SWT.WRAP); message.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); message.setData("org.eclipse.swtbot.widget.key", "preference-status"); Link retry = new Link(this, SWT.NONE); - retry.setText("" + Messages.preferenceRetry + ""); - retry.setData("org.eclipse.swtbot.widget.key", "preference-retry"); - GridData retryData = new GridData(SWT.RIGHT, SWT.CENTER, false, false); - retry.setLayoutData(retryData); + retry.setText("" + Messages.modeDiscoveryRetry + ""); + retry.setData("org.eclipse.swtbot.widget.key", "mode-discovery-retry"); + retry.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false)); retry.addListener(SWT.Selection, event -> { - if (storage.getState() == State.READY && preferences != null) { + if (preferences != null) { preferences.retryModeDiscovery(); - } else { - storage.retry(); } }); + Button dismiss = new Button(this, SWT.PUSH); + dismiss.setText(Messages.preferenceDismiss); + dismiss.setData("org.eclipse.swtbot.widget.key", "preference-dismiss"); + dismiss.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false)); + dismiss.addListener(SWT.Selection, event -> storage.dismissInitializationResult()); Realm.runWithDefault(storage.getReadiness().getRealm(), () -> { ISideEffect effect = ISideEffect.create(() -> { return new Readiness(storage.getReadiness().getValue(), + storage.getInitializationResult().getValue(), preferences == null ? ModeDiscoveryState.READY : preferences.getModeDiscoveryState()); }, readiness -> { if (isDisposed()) { @@ -68,30 +73,34 @@ public PreferenceStatus(Composite parent, PreferenceStorage storage, UserPrefere } State state = readiness.preferences(); boolean modePending = state == State.READY && readiness.modes() != ModeDiscoveryState.READY; - boolean visible = state != State.DISPOSED && (state != State.READY || modePending); + boolean preferenceResult = (state == State.READY && readiness.result() == InitializationResult.RECOVERED) + || (state == State.FAILED && readiness.result() == InitializationResult.FAILED); + boolean visible = state != State.DISPOSED && (preferenceResult || modePending); data.exclude = !visible; setVisible(visible); - String text = switch (state) { - case LOADING -> Messages.preferenceLoading; - case FAILED -> Messages.preferenceLoadFailed; - default -> Messages.preferenceUnavailable; - }; + String text = preferenceResult + ? readiness.result() == InitializationResult.RECOVERED + ? Messages.preferenceRecovered : Messages.preferenceRecoveryFailed + : ""; if (modePending) { text = readiness.modes() == ModeDiscoveryState.LOADING ? Messages.modeDiscoveryLoading : Messages.modeDiscoveryFailed; } message.setText(text); - boolean canRetry = state == State.FAILED || state == State.UNAVAILABLE - || (modePending && readiness.modes() != ModeDiscoveryState.LOADING); - retryData.exclude = !canRetry; - retry.setVisible(canRetry); - retry.setEnabled(canRetry); + boolean canRetryModes = modePending && readiness.modes() == ModeDiscoveryState.FAILED; + retry.setVisible(canRetryModes); + retry.setEnabled(canRetryModes); + boolean canDismiss = preferenceResult && !modePending; + dismiss.setVisible(canDismiss); + dismiss.setEnabled(canDismiss); + ((GridData) retry.getLayoutData()).exclude = !canRetryModes; + ((GridData) dismiss.getLayoutData()).exclude = !canDismiss; requestLayout(); }); addDisposeListener(event -> effect.dispose()); }); } - private record Readiness(State preferences, ModeDiscoveryState modes) { + private record Readiness(State preferences, InitializationResult result, ModeDiscoveryState modes) { } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties index 43aa47fa..807abeec 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties @@ -77,10 +77,10 @@ confirmation_message_fileOperation=The model wants to access sensitive file ({0} # Misc confirmation_autoApprovedDescription=Auto-approved from dialog -# Chat preference loading -preferenceLoading=Loading chat preferences... -preferenceLoadFailed=Could not load chat preferences. Retry to restore your saved settings. -preferenceUnavailable=Chat preferences are unavailable. -preferenceRetry=Retry +# Chat preference initialization results +preferenceRecovered=Unable to restore saved chat preferences. Default settings are being used. +preferenceRecoveryFailed=Unable to initialize chat preferences. Some features are unavailable. See the error log for details. +preferenceDismiss=Dismiss modeDiscoveryLoading=Loading chat modes... modeDiscoveryFailed=Could not load chat modes. Retry to use your selected mode. +modeDiscoveryRetry=Retry diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorage.java index 476d5ba3..8219b6d8 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/PreferenceStorage.java @@ -16,6 +16,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.LongSupplier; import com.google.gson.Gson; @@ -50,6 +51,13 @@ public enum State { UNAVAILABLE, LOADING, READY, FAILED, DISPOSED } + /** + * Final result of restoring preferences, including whether its notification was dismissed. + */ + public enum InitializationResult { + NONE, RECOVERED, RECOVERED_DISMISSED, FAILED, FAILED_DISMISSED + } + private final Object lock = new Object(); private final Object saveLock = new Object(); private final CopilotLanguageServerConnection connection; @@ -59,6 +67,7 @@ public enum State { private final LongSupplier clock; private final FileAccess files; private final WritableValue readiness; + private final WritableValue initializationResult; private final CopilotAuthStatusListener authListener; private State state = State.UNAVAILABLE; private String account; @@ -66,6 +75,8 @@ public enum State { private Attempt attempt; private UserPreference preferences; private Path preferencePath; + private boolean persistenceAllowed; + private InitializationResult result = InitializationResult.NONE; /** * Creates storage without starting RPC or file work. @@ -102,6 +113,8 @@ public void write(Path path, String content) throws IOException { this.clock = clock; this.files = files; this.readiness = new WritableValue<>(realm, State.UNAVAILABLE, State.class); + this.initializationResult = + new WritableValue<>(realm, InitializationResult.NONE, InitializationResult.class); this.account = currentAccount(); this.authListener = status -> refreshAccount(); auth.addCopilotAuthStatusListener(authListener); @@ -129,17 +142,38 @@ public IObservableValue getReadiness() { } /** - * Starts the first load, coalescing callers without implicitly retrying failures. + * Returns the final initialization result and notification dismissal state. + * + * @return the initialization result observable + */ + public IObservableValue getInitializationResult() { + return initializationResult; + } + + /** + * Dismisses a recovery result notification without changing preference readiness. */ - public void initialize() { - start(false); + public void dismissInitializationResult() { + initializationResult.getRealm().asyncExec(() -> { + synchronized (lock) { + if (state == State.DISPOSED || initializationResult.isDisposed()) { + return; + } + result = switch (result) { + case RECOVERED -> InitializationResult.RECOVERED_DISMISSED; + case FAILED -> InitializationResult.FAILED_DISMISSED; + default -> result; + }; + initializationResult.setValue(result); + } + }); } /** - * Explicitly retries unavailable or failed preferences without replacing ready choices. + * Starts initialization, coalescing callers without implicitly retrying failures. */ - public void retry() { - start(true); + public void initialize() { + start(); } /** @@ -175,7 +209,7 @@ void persist(UserPreference expected) { String json; synchronized (lock) { if (state != State.READY || preferences != expected - || !Objects.equals(account, currentAccount())) { + || !persistenceAllowed || !Objects.equals(account, currentAccount())) { return; } path = preferencePath; @@ -208,7 +242,9 @@ public void dispose() { readiness.getRealm().asyncExec(() -> { if (!readiness.isDisposed()) { readiness.setValue(State.DISPOSED); + initializationResult.setValue(InitializationResult.NONE); readiness.dispose(); + initializationResult.dispose(); } }); } @@ -238,6 +274,7 @@ private void refreshAccount() { } cancel(previous); publishState(version, State.UNAVAILABLE); + publishResult(version, InitializationResult.NONE); } private Attempt clear(State next) { @@ -245,24 +282,28 @@ private Attempt clear(State next) { attempt = null; preferences = null; preferencePath = null; + persistenceAllowed = false; + result = InitializationResult.NONE; state = next; generation++; return previous; } - private void start(boolean retry) { + private void start() { refreshAccount(); Attempt next; synchronized (lock) { if (account == null || state == State.DISPOSED || state == State.LOADING || state == State.READY - || (!retry && state == State.FAILED)) { + || state == State.FAILED) { return; } state = State.LOADING; + result = InitializationResult.NONE; next = new Attempt(++generation, account, clock.getAsLong()); attempt = next; } publishState(next.generation, State.LOADING); + publishResult(next.generation, InitializationResult.NONE); try { ScheduledFuture timeout = timer.schedule(() -> fail(next, null), LOAD_TIMEOUT_NANOS, TimeUnit.NANOSECONDS); @@ -411,10 +452,13 @@ private void publishPreferences(Attempt pending, Path path, UserPreference resto } preferences = restored; preferencePath = path; + persistenceAllowed = true; + result = InitializationResult.NONE; state = State.READY; attempt = null; if (!readiness.isDisposed()) { readiness.setValue(State.READY); + initializationResult.setValue(result); } } if (pending.timeout != null) { @@ -445,18 +489,59 @@ private boolean isCurrent(Attempt pending) { private void fail(Attempt pending, Throwable failure) { refreshAccount(); long version; + String failedAccount; synchronized (lock) { if (!matches(pending)) { return; } - clear(State.FAILED); - version = generation; + attempt = null; + preferences = null; + persistenceAllowed = false; + result = InitializationResult.NONE; + state = State.LOADING; + version = ++generation; + failedAccount = account; + preferencePath = null; } cancel(pending); - if (failure != null) { - CopilotCore.LOGGER.error("Failed to load chat preferences", failure); + Throwable restorationFailure = failure == null ? new TimeoutException("Timed out loading chat preferences") + : originalCause(failure); + CopilotCore.LOGGER.error("Failed to load chat preferences", restorationFailure); + try { + publishRecovery(version, failedAccount, new UserPreference(), null); + } catch (RuntimeException recoveryFailure) { + CopilotCore.LOGGER.error("Failed to initialize default chat preferences", recoveryFailure); + publishRecovery(version, failedAccount, null, recoveryFailure); } - publishState(version, State.FAILED); + } + + private Throwable originalCause(Throwable failure) { + Throwable cause = failure; + while ((cause instanceof java.util.concurrent.CompletionException + || cause instanceof java.util.concurrent.ExecutionException) && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause; + } + + private void publishRecovery(long version, String failedAccount, UserPreference defaults, + RuntimeException recoveryFailure) { + readiness.getRealm().asyncExec(() -> { + refreshAccount(); + synchronized (lock) { + if (generation != version || state != State.LOADING || !Objects.equals(account, failedAccount) + || !Objects.equals(account, currentAccount()) || readiness.isDisposed()) { + return; + } + preferences = defaults; + preferencePath = null; + persistenceAllowed = false; + state = recoveryFailure == null ? State.READY : State.FAILED; + result = recoveryFailure == null ? InitializationResult.RECOVERED : InitializationResult.FAILED; + readiness.setValue(state); + initializationResult.setValue(result); + } + }); } private void publishState(long version, State next) { @@ -471,6 +556,18 @@ private void publishState(long version, State next) { }); } + private void publishResult(long version, InitializationResult next) { + initializationResult.getRealm().asyncExec(() -> { + refreshAccount(); + synchronized (lock) { + if (generation != version || result != next || initializationResult.isDisposed()) { + return; + } + initializationResult.setValue(next); + } + }); + } + private void cancel(Attempt pending) { if (pending == null) { return;