Skip to content

Commit 85e9077

Browse files
committed
integrate cls session restore via conversationId and restoreToTurnId
1 parent 2915ec8 commit 85e9077

10 files changed

Lines changed: 194 additions & 40 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ private Constants() {
2828
public static final String WORKSPACE_CONTEXT_ENABLED = "workspaceContextEnabled";
2929
public static final String SUB_AGENT_ENABLED = "subAgentEnabled";
3030
public static final String AGENT_MAX_REQUESTS = "agentMaxRequests";
31+
public static final String TRANSCRIPT_SUBDIR = ".copilot/eclipse";
3132
public static final String MCP = "mcp";
3233
public static final String MCP_REGISTRY_URL = "mcpRegistryUrl";
3334
public static final String MCP_REGISTRY_VERSION = "v0.1";

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,18 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok
275275
List<IResource> files, IFile currentFile, Range currentSelection, List<Turn> turns, CopilotModel activeModel,
276276
String chatModeName, String customChatModeId, List<TodoItem> todos, String agentSlug,
277277
String agentJobWorkspaceFolder) {
278+
return createConversation(workDoneToken, message, files, currentFile, currentSelection, turns, activeModel,
279+
chatModeName, customChatModeId, todos, agentSlug, agentJobWorkspaceFolder, null, null);
280+
}
281+
282+
/**
283+
* Create a conversation with the given parameters, including optional conversationId and restoreToTurnId for session
284+
* restoration.
285+
*/
286+
public CompletableFuture<ChatCreateResult> createConversation(String workDoneToken, String message,
287+
List<IResource> files, IFile currentFile, Range currentSelection, List<Turn> turns, CopilotModel activeModel,
288+
String chatModeName, String customChatModeId, List<TodoItem> todos, String agentSlug,
289+
String agentJobWorkspaceFolder, String conversationId, String restoreToTurnId) {
278290
boolean supportVision = activeModel.getCapabilities().supports().vision();
279291
Either<String, List<ChatCompletionContentPart>> messageWithImages = ChatMessageUtils
280292
.createMessageWithImages(message, FileUtils.filterFilesFrom(files), supportVision);
@@ -305,6 +317,13 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok
305317

306318
// TODO: remove needToolCallConfirmation when CLS fully supports it across all IDEs.
307319
param.setNeedToolCallConfirmation(true);
320+
321+
// Set conversationId and restoreToTurnId for session restoration from history
322+
if (conversationId != null) {
323+
param.setConversationId(conversationId);
324+
param.setRestoreToTurnId(restoreToTurnId);
325+
}
326+
308327
if (currentFile != null) {
309328
param.setTextDocument(new TextDocumentIdentifier(FileUtils.getResourceUri(currentFile)));
310329
if (currentSelection != null) {

com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationCreateParams.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public class ConversationCreateParams {
3939
// TODO: remove needToolCallConfirmation when CLS fully supports it across all IDEs.
4040
private boolean needToolCallConfirmation;
4141
private List<TodoItem> todoList;
42+
private String conversationId;
43+
private String restoreToTurnId;
4244

4345
/**
4446
* Creates a new ConversationCreateParams.
@@ -198,6 +200,22 @@ public void setModelProviderName(String modelProviderName) {
198200
this.modelProviderName = modelProviderName;
199201
}
200202

203+
public String getConversationId() {
204+
return conversationId;
205+
}
206+
207+
public void setConversationId(String conversationId) {
208+
this.conversationId = conversationId;
209+
}
210+
211+
public String getRestoreToTurnId() {
212+
return restoreToTurnId;
213+
}
214+
215+
public void setRestoreToTurnId(String restoreToTurnId) {
216+
this.restoreToTurnId = restoreToTurnId;
217+
}
218+
201219
@Override
202220
public int hashCode() {
203221
final int prime = 31;

com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public class CopilotAgentSettings {
1616
@SerializedName("maxToolCallingLoop")
1717
private int agentMaxRequests;
1818

19+
private String transcriptDirectory;
20+
1921
public int getAgentMaxRequests() {
2022
return agentMaxRequests;
2123
}
@@ -24,9 +26,17 @@ public void setAgentMaxRequests(int agentMaxRequests) {
2426
this.agentMaxRequests = agentMaxRequests;
2527
}
2628

29+
public String getTranscriptDirectory() {
30+
return transcriptDirectory;
31+
}
32+
33+
public void setTranscriptDirectory(String transcriptDirectory) {
34+
this.transcriptDirectory = transcriptDirectory;
35+
}
36+
2737
@Override
2838
public int hashCode() {
29-
return Objects.hash(agentMaxRequests);
39+
return Objects.hash(agentMaxRequests, transcriptDirectory);
3040
}
3141

3242
@Override
@@ -38,13 +48,15 @@ public boolean equals(Object obj) {
3848
return false;
3949
}
4050
CopilotAgentSettings other = (CopilotAgentSettings) obj;
41-
return agentMaxRequests == other.agentMaxRequests;
51+
return agentMaxRequests == other.agentMaxRequests
52+
&& Objects.equals(transcriptDirectory, other.transcriptDirectory);
4253
}
4354

4455
@Override
4556
public String toString() {
4657
ToStringBuilder builder = new ToStringBuilder(this);
4758
builder.append("agentMaxRequests", agentMaxRequests);
59+
builder.append("transcriptDirectory", transcriptDirectory);
4860
return builder.toString();
4961
}
5062
}

com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/Turn.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public class Turn {
1818
Either<String, List<ChatCompletionContentPart>> request;
1919
String response;
2020
String agentSlug;
21+
String turnId;
2122

2223
/**
2324
* Creates a new Turn.
@@ -28,6 +29,17 @@ public Turn(@NonNull Either<String, List<ChatCompletionContentPart>> request, St
2829
this.agentSlug = agentSlug;
2930
}
3031

32+
/**
33+
* Creates a new Turn with turnId.
34+
*/
35+
public Turn(@NonNull Either<String, List<ChatCompletionContentPart>> request, String response, String agentSlug,
36+
String turnId) {
37+
this.request = request;
38+
this.response = response;
39+
this.agentSlug = agentSlug;
40+
this.turnId = turnId;
41+
}
42+
3143
public Either<String, List<ChatCompletionContentPart>> getRequest() {
3244
return request;
3345
}
@@ -52,9 +64,17 @@ public void setAgentSlug(String agentSlug) {
5264
this.agentSlug = agentSlug;
5365
}
5466

67+
public String getTurnId() {
68+
return turnId;
69+
}
70+
71+
public void setTurnId(String turnId) {
72+
this.turnId = turnId;
73+
}
74+
5575
@Override
5676
public int hashCode() {
57-
return Objects.hash(request, response, agentSlug);
77+
return Objects.hash(request, response, agentSlug, turnId);
5878
}
5979

6080
@Override
@@ -67,7 +87,7 @@ public boolean equals(Object o) {
6787
}
6888
Turn turn = (Turn) o;
6989
return Objects.equals(request, turn.request) && Objects.equals(response, turn.response)
70-
&& Objects.equals(agentSlug, turn.agentSlug);
90+
&& Objects.equals(agentSlug, turn.agentSlug) && Objects.equals(turnId, turn.turnId);
7191
}
7292

7393
@Override
@@ -76,6 +96,7 @@ public String toString() {
7696
builder.append("request", request);
7797
builder.append("response", response);
7898
builder.append("agentSlug", agentSlug);
99+
builder.append("turnId", turnId);
79100
return builder.toString();
80101
}
81102
}

com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
2828
import com.microsoft.copilot.eclipse.core.persistence.UserTurnData.MessageData;
2929

30-
3130
/**
3231
* Factory for creating and transforming conversation data objects. Responsible only for pure data transformation with
3332
* no business logic.
@@ -208,22 +207,38 @@ public List<Turn> convertToTurns(List<AbstractTurnData> turnDataList) {
208207
// Defensive copy to avoid ConcurrentModificationException if another thread mutates the list while iterating.
209208
List<AbstractTurnData> snapshot = new ArrayList<>(turnDataList);
210209
List<Turn> result = new ArrayList<>(snapshot.size());
210+
Turn pendingTurn = null;
211+
211212
for (AbstractTurnData turnData : snapshot) {
212213
if (turnData == null) {
213214
continue;
214215
}
215216
if (turnData instanceof UserTurnData userTurnData) {
217+
// Flush any pending turn without a response
218+
if (pendingTurn != null) {
219+
result.add(pendingTurn);
220+
}
216221
String requestText = userTurnData.getMessage() != null ? userTurnData.getMessage().getText() : "";
217222
Either<String, List<ChatCompletionContentPart>> request = Either
218223
.forLeft(requestText == null ? "" : requestText);
219-
result.add(new Turn(request, null, null));
224+
pendingTurn = new Turn(request, null, null, turnData.getTurnId());
220225
} else if (turnData instanceof CopilotTurnData copilotTurnData) {
221-
// Assistant turns only contribute the response text; the request field is intentionally empty.
222226
String responseText = extractResponseFromCopilotTurnData(copilotTurnData);
223-
Either<String, List<ChatCompletionContentPart>> request = Either.forLeft("");
224-
result.add(new Turn(request, responseText, null));
227+
if (pendingTurn != null) {
228+
// Pair the response with the pending user turn
229+
pendingTurn.setResponse(responseText);
230+
result.add(pendingTurn);
231+
pendingTurn = null;
232+
} else {
233+
// Orphaned copilot turn (no preceding user turn), create a standalone turn
234+
result.add(new Turn(Either.forLeft(""), responseText, null, turnData.getTurnId()));
235+
}
225236
}
226237
}
238+
// Flush any remaining pending turn (user message without a response)
239+
if (pendingTurn != null) {
240+
result.add(pendingTurn);
241+
}
227242
return result;
228243
}
229244

com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -294,47 +294,42 @@ private ConversationData updateConversationProgressInternal(String conversationI
294294
return conversationData;
295295
}
296296

297-
private UserTurnData findOrCreateUserTurn(ConversationData conversation, String turnId) {
298-
if (turnId != null) {
299-
AbstractTurnData existingTurn = findTurn(conversation, turnId);
300-
if (existingTurn != null && existingTurn instanceof UserTurnData userTurnData) {
301-
return userTurnData;
297+
/**
298+
* Finds a turn by ID and type in the conversation.
299+
*/
300+
@SuppressWarnings("unchecked")
301+
private <T extends AbstractTurnData> T findTurn(ConversationData conversation, String turnId, Class<T> type) {
302+
if (turnId == null) {
303+
return null;
304+
}
305+
for (AbstractTurnData t : conversation.getTurns()) {
306+
if (turnId.equals(t.getTurnId()) && type.isInstance(t)) {
307+
return (T) t;
302308
}
303309
}
310+
return null;
311+
}
304312

313+
private UserTurnData findOrCreateUserTurn(ConversationData conversation, String turnId) {
314+
UserTurnData existing = findTurn(conversation, turnId, UserTurnData.class);
315+
if (existing != null) {
316+
return existing;
317+
}
305318
UserTurnData turn = dataFactory.createUserTurnData(conversation.getConversationId(), turnId, "", null, null, null);
306319
conversation.getTurns().add(turn);
307320
return turn;
308321
}
309322

310323
private CopilotTurnData findOrCreateCopilotTurn(ConversationData conversation, String turnId) {
311-
if (turnId != null) {
312-
AbstractTurnData existingTurn = findTurn(conversation, turnId);
313-
if (existingTurn != null && existingTurn instanceof CopilotTurnData copilotTurnData) {
314-
return copilotTurnData;
315-
}
324+
CopilotTurnData existing = findTurn(conversation, turnId, CopilotTurnData.class);
325+
if (existing != null) {
326+
return existing;
316327
}
317-
318328
CopilotTurnData turn = dataFactory.createCopilotTurnData(turnId);
319329
conversation.getTurns().add(turn);
320330
return turn;
321331
}
322332

323-
/**
324-
* Finds a turn by ID in the conversation.
325-
*/
326-
private AbstractTurnData findTurn(ConversationData conversation, String turnId) {
327-
if (conversation == null || turnId == null) {
328-
return null;
329-
}
330-
for (AbstractTurnData t : conversation.getTurns()) {
331-
if (turnId.equals(t.getTurnId())) {
332-
return t;
333-
}
334-
}
335-
return null;
336-
}
337-
338333
private ConversationData getOrCreateNewConversationById(String conversationId) throws IOException {
339334
try {
340335
ConversationData existedConversation = getConversationFromCacheOrLoadFromDisk(conversationId);
@@ -543,4 +538,40 @@ public CompletableFuture<Void> persistModelInfo(String conversationId, String tu
543538
public ConversationDataFactory getDataFactory() {
544539
return dataFactory;
545540
}
541+
542+
/**
543+
* Sets the CLS-assigned turnId on the last user turn that doesn't have a turnId yet. User turns are initially
544+
* persisted without a turnId (null), and the server-assigned turnId is set when the CLS progress begin event arrives.
545+
*
546+
* @param conversationId the conversation ID
547+
* @param turnId the server-assigned turnId from CLS
548+
*/
549+
public void setUserTurnId(String conversationId, String turnId) {
550+
if (turnId == null) {
551+
return;
552+
}
553+
CompletableFuture.runAsync(() -> {
554+
lock.writeLock().lock();
555+
try {
556+
ConversationData conversation = getConversationFromCacheOrLoadFromDisk(conversationId);
557+
if (conversation == null) {
558+
return;
559+
}
560+
// Find the last UserTurnData with null turnId and set it
561+
List<AbstractTurnData> turns = conversation.getTurns();
562+
for (int i = turns.size() - 1; i >= 0; i--) {
563+
AbstractTurnData t = turns.get(i);
564+
if (t instanceof UserTurnData && t.getTurnId() == null) {
565+
t.setTurnId(turnId);
566+
persistAndCacheConversation(conversation);
567+
break;
568+
}
569+
}
570+
} catch (IOException e) {
571+
CopilotCore.LOGGER.error("Failed to set user turn ID for conversation: " + conversationId, e);
572+
} finally {
573+
lock.writeLock().unlock();
574+
}
575+
});
576+
}
546577
}

com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/PlatformUtils.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
package com.microsoft.copilot.eclipse.core.utils;
55

6+
import java.io.File;
67
import java.io.IOException;
78
import java.lang.reflect.Field;
89
import java.net.URI;
@@ -25,6 +26,7 @@
2526
import org.osgi.framework.Bundle;
2627
import org.osgi.framework.Version;
2728

29+
import com.microsoft.copilot.eclipse.core.Constants;
2830
import com.microsoft.copilot.eclipse.core.CopilotCore;
2931

3032
/**
@@ -150,6 +152,15 @@ public static boolean isArm64() {
150152
return Platform.getOSArch().equals(Platform.ARCH_AARCH64);
151153
}
152154

155+
/**
156+
* Returns the transcript directory for CLS session persistence, following the same convention as the IntelliJ
157+
* Copilot plugin ({@code ~/.copilot/eclipse}).
158+
*/
159+
public static String getTranscriptDirectory() {
160+
String userHome = System.getProperty("user.home");
161+
return new File(userHome, Constants.TRANSCRIPT_SUBDIR).getAbsolutePath();
162+
}
163+
153164
/**
154165
* get the property value of the object with reflection.
155166
*

0 commit comments

Comments
 (0)