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,29 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

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

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;

import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

class UserPreferenceTests {

private static final Gson GSON = new Gson();

@Test
void testContextWindowPreference_deserializesLegacyStringAndSerializesNumber() {
UserPreference preference = GSON.fromJson(
"{\"contextWindowByModel\":{\"gpt-5\":\"1000000\"}}", UserPreference.class);

assertEquals(1_000_000, preference.getContextWindow("gpt-5"));

JsonObject serialized = JsonParser.parseString(GSON.toJson(preference)).getAsJsonObject();
assertTrue(serialized.getAsJsonObject("contextWindowByModel").get("gpt-5").getAsJsonPrimitive().isNumber());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public class UserPreference {
*/
private volatile Map<String, String> reasoningEffortByModel = Map.of();

/**
* User-selected context-window size (a price tier's {@code maxContext} token count) keyed by the composite model
* key (matching the {@link #chatModel} format).
*/
private volatile Map<String, Integer> contextWindowByModel = Map.of();

/**
* Gets the id of the Chat model.
*
Expand Down Expand Up @@ -166,9 +172,98 @@ public synchronized boolean setReasoningEfforts(Map<String, String> reasoningEff
return true;
}

/**
* Returns the user-selected context-window size for the given model key, or {@code null} when the user has not made
* an explicit selection.
*
* @param modelKey the composite model key (matching the {@link #getChatModel()} format)
* @return the previously selected context-window size, or {@code null}
*/
Comment thread
Copilot marked this conversation as resolved.
public Integer getContextWindow(String modelKey) {
if (modelKey == null) {
return null;
}
return contextWindowByModel.get(modelKey);
}

/**
* Returns an immutable snapshot of the model-key to context-window map. Useful as an observable value that changes
* by equality whenever any individual entry is added, updated, or removed.
*
* @return an immutable snapshot of the current context-window map (never {@code null})
*/
public Map<String, Integer> getContextWindowSnapshot() {
return contextWindowByModel;
}

/**
* Stores the user-selected context-window size for the given model key. Passing a {@code null} value clears any
* previously stored value for that key.
*
* <p>The underlying map is replaced atomically with a fresh immutable snapshot via {@link Map#copyOf}, so concurrent
* readers (including Gson reflectively serializing this preference on a background thread) always observe either
* the old or the new snapshot, never a partially mutated map.
*
* @param modelKey the composite model key (matching the {@link #getChatModel()} format)
* @param contextWindow the context-window size to store, or {@code null} to clear
* @return {@code true} when the stored context window changed, {@code false} otherwise
*/
public synchronized boolean setContextWindow(String modelKey, Integer contextWindow) {
if (modelKey == null) {
return false;
}
Map<String, Integer> current = contextWindowByModel;
if (contextWindow == null) {
if (!current.containsKey(modelKey)) {
return false;
}
Map<String, Integer> next = new HashMap<>(current);
next.remove(modelKey);
contextWindowByModel = Map.copyOf(next);
return true;
}
if (contextWindow.equals(current.get(modelKey))) {
return false;
}
Map<String, Integer> next = new HashMap<>(current);
next.put(modelKey, contextWindow);
contextWindowByModel = Map.copyOf(next);
return true;
}

/**
* Atomically replaces the entire context-window map with the given snapshot, dropping any keys not present in
* {@code contextWindowsByModel}. {@code null} entries in the input map are ignored. Returns {@code true} when the
* new snapshot differs from the previous one (so callers can decide whether to persist or notify observers).
*
* @param contextWindowsByModel the new context-window map keyed by composite model key (matching the
* {@link #getChatModel()} format); may be {@code null} or empty to clear all entries
* @return {@code true} when the stored map changed, {@code false} otherwise
*/
public synchronized boolean setContextWindows(Map<String, Integer> contextWindowsByModel) {
Map<String, Integer> updatedContextWindows;
if (contextWindowsByModel == null || contextWindowsByModel.isEmpty()) {
updatedContextWindows = Map.of();
} else {
Map<String, Integer> copy = new HashMap<>();
for (Map.Entry<String, Integer> entry : contextWindowsByModel.entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
copy.put(entry.getKey(), entry.getValue());
}
}
updatedContextWindows = Map.copyOf(copy);
}
if (updatedContextWindows.equals(this.contextWindowByModel)) {
return false;
}
this.contextWindowByModel = updatedContextWindows;
return true;
}

@Override
public int hashCode() {
return Objects.hash(chatModeName, chatModel, userInputs, skipGitHubJobConfirmDialog, reasoningEffortByModel);
return Objects.hash(chatModeName, chatModel, userInputs, skipGitHubJobConfirmDialog, reasoningEffortByModel,
contextWindowByModel);
}

@Override
Expand All @@ -186,7 +281,8 @@ public boolean equals(Object obj) {
return Objects.equals(chatModeName, other.chatModeName) && Objects.equals(chatModel, other.chatModel)
&& Objects.equals(userInputs, other.userInputs)
&& skipGitHubJobConfirmDialog == other.skipGitHubJobConfirmDialog
&& Objects.equals(reasoningEffortByModel, other.reasoningEffortByModel);
&& Objects.equals(reasoningEffortByModel, other.reasoningEffortByModel)
&& Objects.equals(contextWindowByModel, other.contextWindowByModel);
}

@Override
Expand All @@ -197,6 +293,7 @@ public String toString() {
builder.append("userInputs", userInputs);
builder.append("skipGitHubJobConfirmDialog", skipGitHubJobConfirmDialog);
builder.append("reasoningEffortByModel", reasoningEffortByModel);
builder.append("contextWindowByModel", contextWindowByModel);
return builder.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -265,13 +265,13 @@ public CompletableFuture<Object> sendExceptionTelemetry(Throwable ex) {
}

/**
* Create a conversation with the given parameters, including an optional reasoning effort to forward to the server
* via {@code modelInfo}.
* Create a conversation with the given parameters, including an optional reasoning effort and context-window size to
* forward to the server via {@code modelInfo}.
*/
public CompletableFuture<ChatCreateResult> createConversation(String workDoneToken, String message,
List<IResource> files, IFile currentFile, Range currentSelection, List<Turn> turns, CopilotModel activeModel,
String reasoningEffort, String chatModeName, String customChatModeId, List<TodoItem> todos, String agentSlug,
String agentJobWorkspaceFolder, String conversationId, String restoreToTurnId,
String reasoningEffort, Integer contextSize, String chatModeName, String customChatModeId, List<TodoItem> todos,
String agentSlug, String agentJobWorkspaceFolder, String conversationId, String restoreToTurnId,
List<WorkspaceFolder> workspaceFolders) {

boolean supportVision = activeModel.getCapabilities().supports().vision();
Expand All @@ -282,7 +282,7 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok
param.setReferences(FileUtils.convertToChatReferences(files));
param.setModel(getModelName(activeModel));
param.setModelProviderName(activeModel.getProviderName());
param.setModelInfo(buildModelInfo(activeModel, reasoningEffort));
param.setModelInfo(buildModelInfo(activeModel, reasoningEffort, contextSize));
param.setChatMode(chatModeName);
param.setCustomChatModeId(customChatModeId);

Expand Down Expand Up @@ -323,13 +323,14 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok
}

/**
* Create a conversation turn with the given parameters, including an optional reasoning effort to forward to the
* server via {@code modelInfo}.
* Create a conversation turn with the given parameters, including an optional reasoning effort and context-window
* size to forward to the server via {@code modelInfo}.
*/
public CompletableFuture<ChatTurnResult> addConversationTurn(String workDoneToken, String conversationId,
String message, List<IResource> files, IFile currentFile, Range currentSelection, CopilotModel activeModel,
String reasoningEffort, String chatModeName, String customChatModeId, List<TodoItem> todoList, String agentSlug,
String agentJobWorkspaceFolder, List<WorkspaceFolder> workspaceFolders) {
String reasoningEffort, Integer contextSize, String chatModeName, String customChatModeId,
List<TodoItem> todoList, String agentSlug, String agentJobWorkspaceFolder,
List<WorkspaceFolder> workspaceFolders) {

boolean supportVision = activeModel.getCapabilities().supports().vision();
Either<String, List<ChatCompletionContentPart>> messageWithImages = ChatMessageUtils
Expand All @@ -339,7 +340,7 @@ public CompletableFuture<ChatTurnResult> addConversationTurn(String workDoneToke
param.setReferences(FileUtils.convertToChatReferences(files));
param.setModel(getModelName(activeModel));
param.setModelProviderName(activeModel.getProviderName());
param.setModelInfo(buildModelInfo(activeModel, reasoningEffort));
param.setModelInfo(buildModelInfo(activeModel, reasoningEffort, contextSize));
param.setChatMode(chatModeName);
param.setCustomChatModeId(customChatModeId);

Expand Down Expand Up @@ -741,11 +742,11 @@ private String getModelName(CopilotModel activeModel) {
* prefers over the legacy {@code model} family field), since the family is not unique across models. Returns
* {@code null} when no model id is available.
*/
private static ModelInfo buildModelInfo(CopilotModel activeModel, String reasoningEffort) {
private static ModelInfo buildModelInfo(CopilotModel activeModel, String reasoningEffort, Integer contextSize) {
if (activeModel == null || StringUtils.isBlank(activeModel.getId())) {
return null;
}
String effort = StringUtils.isBlank(reasoningEffort) ? null : reasoningEffort;
return new ModelInfo(activeModel.getId(), activeModel.getProviderName(), effort, null);
return new ModelInfo(activeModel.getId(), activeModel.getProviderName(), effort, contextSize);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
* @param id model identifier (optional)
* @param providerName provider name (optional)
* @param reasoningEffort user-selected reasoning effort (optional)
* @param contextSize context size (optional)
* @param contextSize user-selected context-window size in tokens (optional). Sent as a JSON number to match the
* language server's {@code modelInfo.contextSize} schema.
*/
public record ModelInfo(String id, String providerName, String reasoningEffort, String contextSize) {
public record ModelInfo(String id, String providerName, String reasoningEffort, Integer contextSize) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

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

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.Test;

import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel;
import com.microsoft.copilot.eclipse.ui.swt.DropdownItemGroup;

class ModelPickerGroupsBuilderTests {

@Test
void testBuild_selectedLabelIncludesContextWindowAndReasoningEffort() {
CopilotModel model = new CopilotModel();
model.setModelName("gpt-5");

List<DropdownItemGroup> groups = ModelPickerGroupsBuilder.build(Map.of("gpt-5", model), false, false,
ignored -> "high", ignored -> "1M");

assertEquals("gpt-5 - 1M - High", groups.get(0).getItems().get(0).getSelectedLabel());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package com.microsoft.copilot.eclipse.ui.chat.services;

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.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
Expand Down Expand Up @@ -210,6 +211,26 @@ void testDefaultKeyCollisionSelectsModelFromCurrentInventory() throws IOExceptio
assertSame(pickerModel.get(), activeModel.get());
}

@Test
void testSetActiveModel_AlreadyActiveModelDoesNotPersistPreference() throws InterruptedException {
CopilotModel defaultModel = createModel("gpt-4o", "GPT-4o", true);
when(lsConnection.listModels())
.thenReturn(CompletableFuture.completedFuture(new CopilotModel[] { defaultModel }));

modelService = new ModelService(lsConnection, authStatusManager);
waitUntil(() -> defaultModel.getId().equals(getActiveModelId()));

AtomicReference<CopilotModel> activeModel = new AtomicReference<>();
Display.getDefault().syncExec(() -> {
modelService.setActiveModel(defaultModel.getModelName());
activeModel.set(modelService.getActiveModel());
});

assertSame(defaultModel, activeModel.get());
Thread.sleep(100);
assertFalse(Files.exists(getPreferenceFile()));
}

private static CopilotModel createModel(String id, String name, boolean isChatDefault) {
CopilotModel model = new CopilotModel();
model.setId(id);
Expand Down
Loading
Loading