Skip to content

Commit 9aeae2b

Browse files
authored
feat - Integrate CLS session persistence and restoration for conversation history (#161)
1 parent d269a2b commit 9aeae2b

11 files changed

Lines changed: 220 additions & 49 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
@@ -278,6 +278,18 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok
278278
List<IResource> files, IFile currentFile, Range currentSelection, List<Turn> turns, CopilotModel activeModel,
279279
String chatModeName, String customChatModeId, List<TodoItem> todos, String agentSlug,
280280
String agentJobWorkspaceFolder) {
281+
return createConversation(workDoneToken, message, files, currentFile, currentSelection, turns, activeModel,
282+
chatModeName, customChatModeId, todos, agentSlug, agentJobWorkspaceFolder, null, null);
283+
}
284+
285+
/**
286+
* Create a conversation with the given parameters, including optional conversationId and restoreToTurnId for session
287+
* restoration.
288+
*/
289+
public CompletableFuture<ChatCreateResult> createConversation(String workDoneToken, String message,
290+
List<IResource> files, IFile currentFile, Range currentSelection, List<Turn> turns, CopilotModel activeModel,
291+
String chatModeName, String customChatModeId, List<TodoItem> todos, String agentSlug,
292+
String agentJobWorkspaceFolder, String conversationId, String restoreToTurnId) {
281293
boolean supportVision = activeModel.getCapabilities().supports().vision();
282294
Either<String, List<ChatCompletionContentPart>> messageWithImages = ChatMessageUtils
283295
.createMessageWithImages(message, FileUtils.filterFilesFrom(files), supportVision);
@@ -308,6 +320,13 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok
308320

309321
// TODO: remove needToolCallConfirmation when CLS fully supports it across all IDEs.
310322
param.setNeedToolCallConfirmation(true);
323+
324+
// Set conversationId and restoreToTurnId for session restoration from history
325+
if (conversationId != null) {
326+
param.setConversationId(conversationId);
327+
param.setRestoreToTurnId(restoreToTurnId);
328+
}
329+
311330
if (currentFile != null) {
312331
param.setTextDocument(new TextDocumentIdentifier(FileUtils.getResourceUri(currentFile)));
313332
if (currentSelection != null) {

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

Lines changed: 24 additions & 2 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;
@@ -207,7 +225,7 @@ public int hashCode() {
207225
result = prime * result
208226
+ Objects.hash(capabilities, chatMode, computeSuggestions, customChatModeId, model, needToolCallConfirmation,
209227
references, source, textDocument, userLanguage, workDoneToken, workspaceFolder, workspaceFolders,
210-
modelProviderName, todoList);
228+
modelProviderName, todoList, conversationId, restoreToTurnId);
211229
return result;
212230
}
213231

@@ -233,7 +251,9 @@ public boolean equals(Object obj) {
233251
&& Objects.equals(workDoneToken, other.workDoneToken) && Objects.equals(workspaceFolder, other.workspaceFolder)
234252
&& Objects.equals(workspaceFolders, other.workspaceFolders)
235253
&& Objects.equals(modelProviderName, other.modelProviderName)
236-
&& Objects.equals(todoList, other.todoList);
254+
&& Objects.equals(todoList, other.todoList)
255+
&& Objects.equals(conversationId, other.conversationId)
256+
&& Objects.equals(restoreToTurnId, other.restoreToTurnId);
237257
}
238258

239259
@Override
@@ -256,6 +276,8 @@ public String toString() {
256276
builder.append("customChatModeId", customChatModeId);
257277
builder.append("needToolCallConfirmation", needToolCallConfirmation);
258278
builder.append("todoList", todoList);
279+
builder.append("conversationId", conversationId);
280+
builder.append("restoreToTurnId", restoreToTurnId);
259281
return builder.toString();
260282
}
261283
}

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,6 +207,8 @@ 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 unpairedUserTurn = null;
211+
211212
for (AbstractTurnData turnData : snapshot) {
212213
if (turnData == null) {
213214
continue;
@@ -218,17 +219,31 @@ public List<Turn> convertToTurns(List<AbstractTurnData> turnDataList) {
218219
continue;
219220
}
220221
if (turnData instanceof UserTurnData userTurnData) {
222+
// Flush any unpaired user turn without a response
223+
if (unpairedUserTurn != null) {
224+
result.add(unpairedUserTurn);
225+
}
221226
String requestText = userTurnData.getMessage() != null ? userTurnData.getMessage().getText() : "";
222227
Either<String, List<ChatCompletionContentPart>> request = Either
223228
.forLeft(requestText == null ? "" : requestText);
224-
result.add(new Turn(request, null, null));
229+
unpairedUserTurn = new Turn(request, null, null, turnData.getTurnId());
225230
} else if (turnData instanceof CopilotTurnData copilotTurnData) {
226-
// Assistant turns only contribute the response text; the request field is intentionally empty.
227231
String responseText = extractResponseFromCopilotTurnData(copilotTurnData);
228-
Either<String, List<ChatCompletionContentPart>> request = Either.forLeft("");
229-
result.add(new Turn(request, responseText, null));
232+
if (unpairedUserTurn != null) {
233+
// Pair the response with the unpaired user turn
234+
unpairedUserTurn.setResponse(responseText);
235+
result.add(unpairedUserTurn);
236+
unpairedUserTurn = null;
237+
} else {
238+
// Orphaned copilot turn (no preceding user turn), create a standalone turn
239+
result.add(new Turn(Either.forLeft(""), responseText, null, turnData.getTurnId()));
240+
}
230241
}
231242
}
243+
// Flush any remaining unpaired user turn (user message without a response)
244+
if (unpairedUserTurn != null) {
245+
result.add(unpairedUserTurn);
246+
}
232247
return result;
233248
}
234249

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

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -299,47 +299,42 @@ private ConversationData updateConversationProgressInternal(String conversationI
299299
return conversationData;
300300
}
301301

302-
private UserTurnData findOrCreateUserTurn(ConversationData conversation, String turnId) {
303-
if (turnId != null) {
304-
AbstractTurnData existingTurn = findTurn(conversation, turnId);
305-
if (existingTurn != null && existingTurn instanceof UserTurnData userTurnData) {
306-
return userTurnData;
302+
/**
303+
* Finds a turn by ID and type in the conversation.
304+
*/
305+
@SuppressWarnings("unchecked")
306+
private <T extends AbstractTurnData> T findTurn(ConversationData conversation, String turnId, Class<T> type) {
307+
if (turnId == null) {
308+
return null;
309+
}
310+
for (AbstractTurnData t : conversation.getTurns()) {
311+
if (turnId.equals(t.getTurnId()) && type.isInstance(t)) {
312+
return (T) t;
307313
}
308314
}
315+
return null;
316+
}
309317

318+
private UserTurnData findOrCreateUserTurn(ConversationData conversation, String turnId) {
319+
UserTurnData existing = findTurn(conversation, turnId, UserTurnData.class);
320+
if (existing != null) {
321+
return existing;
322+
}
310323
UserTurnData turn = dataFactory.createUserTurnData(conversation.getConversationId(), turnId, "", null, null, null);
311324
conversation.getTurns().add(turn);
312325
return turn;
313326
}
314327

315328
private CopilotTurnData findOrCreateCopilotTurn(ConversationData conversation, String turnId) {
316-
if (turnId != null) {
317-
AbstractTurnData existingTurn = findTurn(conversation, turnId);
318-
if (existingTurn != null && existingTurn instanceof CopilotTurnData copilotTurnData) {
319-
return copilotTurnData;
320-
}
329+
CopilotTurnData existing = findTurn(conversation, turnId, CopilotTurnData.class);
330+
if (existing != null) {
331+
return existing;
321332
}
322-
323333
CopilotTurnData turn = dataFactory.createCopilotTurnData(turnId);
324334
conversation.getTurns().add(turn);
325335
return turn;
326336
}
327337

328-
/**
329-
* Finds a turn by ID in the conversation.
330-
*/
331-
private AbstractTurnData findTurn(ConversationData conversation, String turnId) {
332-
if (conversation == null || turnId == null) {
333-
return null;
334-
}
335-
for (AbstractTurnData t : conversation.getTurns()) {
336-
if (turnId.equals(t.getTurnId())) {
337-
return t;
338-
}
339-
}
340-
return null;
341-
}
342-
343338
private ConversationData getOrCreateNewConversationById(String conversationId) throws IOException {
344339
try {
345340
ConversationData existedConversation = getConversationFromCacheOrLoadFromDisk(conversationId);
@@ -549,6 +544,42 @@ public ConversationDataFactory getDataFactory() {
549544
return dataFactory;
550545
}
551546

547+
/**
548+
* Sets the CLS-assigned turnId on the last user turn that doesn't have a turnId yet. User turns are initially
549+
* persisted without a turnId (null), and the server-assigned turnId is set when the CLS progress begin event arrives.
550+
*
551+
* @param conversationId the conversation ID
552+
* @param turnId the server-assigned turnId from CLS
553+
*/
554+
public void setUserTurnId(String conversationId, String turnId) {
555+
if (turnId == null) {
556+
return;
557+
}
558+
CompletableFuture.runAsync(() -> {
559+
lock.writeLock().lock();
560+
try {
561+
ConversationData conversation = getConversationFromCacheOrLoadFromDisk(conversationId);
562+
if (conversation == null) {
563+
return;
564+
}
565+
// Find the last UserTurnData with null turnId and set it
566+
List<AbstractTurnData> turns = conversation.getTurns();
567+
for (int i = turns.size() - 1; i >= 0; i--) {
568+
AbstractTurnData t = turns.get(i);
569+
if (t instanceof UserTurnData && t.getTurnId() == null) {
570+
t.setTurnId(turnId);
571+
persistAndCacheConversation(conversation);
572+
break;
573+
}
574+
}
575+
} catch (IOException e) {
576+
CopilotCore.LOGGER.error("Failed to set user turn ID for conversation: " + conversationId, e);
577+
} finally {
578+
lock.writeLock().unlock();
579+
}
580+
});
581+
}
582+
552583
/**
553584
* Sets the subagentToolCallId on a subagent's CopilotTurnData to associate it with the parent turn's run_subagent
554585
* tool call. This enables precise positioning of subagent content during conversation restoration.
@@ -570,9 +601,9 @@ public CompletableFuture<Void> setSubagentToolCallId(String conversationId, Stri
570601
if (conversation == null) {
571602
return;
572603
}
573-
AbstractTurnData turnData = findTurn(conversation, subagentTurnId);
574-
if (turnData instanceof CopilotTurnData turn && turn.getSubagentToolCallId() == null) {
575-
turn.setSubagentToolCallId(toolCallId);
604+
CopilotTurnData turnData = findTurn(conversation, subagentTurnId, CopilotTurnData.class);
605+
if (turnData != null && turnData.getSubagentToolCallId() == null) {
606+
turnData.setSubagentToolCallId(toolCallId);
576607
}
577608
} catch (IOException e) {
578609
CopilotCore.LOGGER.error("Failed to set subagent tool call ID: " + conversationId, e);

0 commit comments

Comments
 (0)