From b0265d9baf659908af7400d9c418386ab620ff03 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Sat, 9 May 2026 16:38:30 +0800 Subject: [PATCH 1/3] fix subagent restoration related issues --- .../core/lsp/CopilotLanguageServer.java | 7 + .../lsp/CopilotLanguageServerConnection.java | 16 ++ .../protocol/ConversationDestroyParams.java | 54 +++++ .../persistence/ConversationDataFactory.java | 5 + .../ConversationPersistenceManager.java | 36 ++++ .../core/persistence/CopilotTurnData.java | 26 ++- .../test-plans/subagent/subagent.md | 199 ++++++++++++++++++ .../eclipse/ui/chat/BaseTurnWidget.java | 64 ++++++ .../copilot/eclipse/ui/chat/ChatView.java | 192 ++++++++++++----- 9 files changed, 541 insertions(+), 58 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationDestroyParams.java create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java index 8065520a..db5bf6e8 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java @@ -25,6 +25,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationAgent; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCodeCopyParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCreateParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationDestroyParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationMode; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationModesParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; @@ -159,6 +160,12 @@ public interface CopilotLanguageServer extends LanguageServer { @JsonRequest("conversation/persistence") CompletableFuture persistence(NullParams param); + /** + * Destroy a conversation, stopping any in-progress processing. + */ + @JsonRequest("conversation/destroy") + CompletableFuture destroy(ConversationDestroyParams param); + /** * Register agent tools to the language server. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java index 44892c10..0d2805d4 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java @@ -42,6 +42,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationAgent; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCodeCopyParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCreateParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationDestroyParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationMode; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationModesParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; @@ -429,6 +430,21 @@ public CompletableFuture persistence() { }); } + /** + * Destroy a conversation, stopping any in-progress processing on the server. + */ + public void destroyConversation(String conversationId) { + if (StringUtils.isBlank(conversationId)) { + return; + } + Function> fn = server -> ((CopilotLanguageServer) server) + .destroy(new ConversationDestroyParams(conversationId)); + this.languageServerWrapper.execute(fn).exceptionally(ex -> { + CopilotCore.LOGGER.error("Failed to destroy conversation: " + conversationId, ex); + return null; + }); + } + /** * Used to register the tools for the language server. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationDestroyParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationDestroyParams.java new file mode 100644 index 00000000..87459397 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationDestroyParams.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Parameters for destroying a conversation. + */ +public class ConversationDestroyParams { + private String conversationId; + + /** + * Creates a new ConversationDestroyParams. + */ + public ConversationDestroyParams(String conversationId) { + this.conversationId = conversationId; + } + + public String getConversationId() { + return conversationId; + } + + public void setConversationId(String conversationId) { + this.conversationId = conversationId; + } + + @Override + public int hashCode() { + return Objects.hash(conversationId); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ConversationDestroyParams other = (ConversationDestroyParams) obj; + return Objects.equals(conversationId, other.conversationId); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("conversationId", conversationId); + return builder.toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java index 530f3baa..945a6fe8 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java @@ -212,6 +212,11 @@ public List convertToTurns(List turnDataList) { if (turnData == null) { continue; } + // Skip subagent turns - they are not part of the main conversation history + if (turnData instanceof CopilotTurnData copilotCheck + && copilotCheck.getParentTurnId() != null) { + continue; + } if (turnData instanceof UserTurnData userTurnData) { String requestText = userTurnData.getMessage() != null ? userTurnData.getMessage().getText() : ""; Either> request = Either diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java index 304ae253..f171f272 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java @@ -286,6 +286,11 @@ private ConversationData updateConversationProgressInternal(String conversationI CopilotTurnData copilotTurnData = findOrCreateCopilotTurn(conversationData, progress.getTurnId()); dataFactory.updateReplyFromProgress(copilotTurnData.getReply(), progress); + // Mark subagent turns with their parent turn ID + if (StringUtils.isNotBlank(progress.getParentTurnId())) { + copilotTurnData.setParentTurnId(progress.getParentTurnId()); + } + // Update suggested title in CopilotTurnData if present if (StringUtils.isNotBlank(progress.getSuggestedTitle())) { copilotTurnData.setSuggestedTitle(progress.getSuggestedTitle()); @@ -543,4 +548,35 @@ public CompletableFuture persistModelInfo(String conversationId, String tu public ConversationDataFactory getDataFactory() { return dataFactory; } + + /** + * Sets the subagentToolCallId on a subagent's CopilotTurnData to associate it with the parent turn's run_subagent + * tool call. This enables precise positioning of subagent content during conversation restoration. + * + * @param conversationId the conversation ID + * @param subagentTurnId the subagent's turn ID + * @param toolCallId the run_subagent tool call ID from the parent turn + */ + public void setSubagentToolCallId(String conversationId, String subagentTurnId, String toolCallId) { + if (toolCallId == null || subagentTurnId == null) { + return; + } + CompletableFuture.runAsync(() -> { + lock.writeLock().lock(); + try { + ConversationData conversation = getConversationFromCacheOrLoadFromDisk(conversationId); + if (conversation == null) { + return; + } + AbstractTurnData turnData = findTurn(conversation, subagentTurnId); + if (turnData instanceof CopilotTurnData turn && turn.getSubagentToolCallId() == null) { + turn.setSubagentToolCallId(toolCallId); + } + } catch (IOException e) { + CopilotCore.LOGGER.error("Failed to set subagent tool call ID: " + conversationId, e); + } finally { + lock.writeLock().unlock(); + } + }); + } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java index 91e8818f..d614bf87 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java @@ -19,6 +19,8 @@ public class CopilotTurnData extends AbstractTurnData { private ReplyData reply; private String suggestedTitle; + private String parentTurnId; + private String subagentToolCallId; /** * Default constructor initializing default values. @@ -43,11 +45,27 @@ public void setSuggestedTitle(String suggestedTitle) { this.suggestedTitle = suggestedTitle; } + public String getParentTurnId() { + return parentTurnId; + } + + public void setParentTurnId(String parentTurnId) { + this.parentTurnId = parentTurnId; + } + + public String getSubagentToolCallId() { + return subagentToolCallId; + } + + public void setSubagentToolCallId(String subagentToolCallId) { + this.subagentToolCallId = subagentToolCallId; + } + @Override public int hashCode() { final int prime = 31; int result = super.hashCode(); - result = prime * result + Objects.hash(reply, suggestedTitle); + result = prime * result + Objects.hash(reply, suggestedTitle, parentTurnId, subagentToolCallId); return result; } @@ -63,7 +81,9 @@ public boolean equals(Object obj) { return false; } CopilotTurnData other = (CopilotTurnData) obj; - return Objects.equals(reply, other.reply) && Objects.equals(suggestedTitle, other.suggestedTitle); + return Objects.equals(reply, other.reply) && Objects.equals(suggestedTitle, other.suggestedTitle) + && Objects.equals(parentTurnId, other.parentTurnId) + && Objects.equals(subagentToolCallId, other.subagentToolCallId); } @Override @@ -77,6 +97,8 @@ public String toString() { // Include CopilotTurnData specific properties builder.append("reply", reply); builder.append("suggestedTitle", suggestedTitle); + builder.append("parentTurnId", parentTurnId); + builder.append("subagentToolCallId", subagentToolCallId); return builder.toString(); } diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md new file mode 100644 index 00000000..86268365 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md @@ -0,0 +1,199 @@ +# Subagent: Execution, Persistence, and Session Switching + +## Overview +Tests that subagent execution (`run_subagent` tool call) is correctly +displayed during live execution, persisted to conversation history, and +properly restored when switching between conversations via chat history. + +The observable signals include the `SubagentMessageBlock` card (border + +agent name header), individual tool-call status labels inside the card, +and the main agent's summary text appearing after the subagent block. +A failure in persistence, progress-event routing, or restoration logic +breaks these signals. + +Entry points exercised: +- Agent mode with a prompt that triggers `run_subagent`. +- Chat history panel: switching to a different conversation and back. +- Cancel (stop) button during subagent execution. +- `conversation/destroy` LSP call on session switch. + +Not exercised: +- Subagent tool-call confirmation dialogs. +- Subagent error recovery / retry. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- **A signed-in Copilot account.** +- Network access to `api.githubcopilot.com`. +- Agent mode selected in the chat mode picker. +- At least one custom agent available (e.g., the built-in CVE Remediator + or a workspace `.github/agents/*.md` agent). + +--- + +## 1. Live subagent execution + +### TC-001: Subagent executes and renders nested card + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Chat view is open in Agent mode. +- No previous conversation is active (fresh session). + +#### Steps +1. Type a prompt that triggers subagent execution (e.g., `invoke a subagent` + or `scan for CVEs`). +2. Click **Send**. +3. Wait for the main agent to invoke `run_subagent` — a bordered + `SubagentMessageBlock` card should appear with the agent name header + (e.g., "CVE Remediator: Scan dependencies for CVEs"). +4. Wait for the subagent to finish executing — tool-call status labels + (checkmark icons) should appear inside the card. +5. Wait for the main agent to produce its summary response after the + subagent block. +6. The send button returns to the send state. + +#### Expected Result +- A `SubagentMessageBlock` card renders with agent name and description. +- Subagent tool calls (e.g., "Searched for files matching query", + "Ran MCP tool") appear inside the card with status icons. +- The main agent's summary text appears below the subagent block. +- No orphaned tool-call labels outside the card. + +#### Key Screenshots +- [ ] **Subagent running** — card header visible, tool calls streaming in. +- [ ] **Subagent completed** — all tool calls show checkmark, main agent + summary visible below. + +--- + +## 2. Persistence and restoration + +### TC-002: Subagent content restored after session switch (completed) + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-001 completed successfully (subagent fully finished). + +#### Steps +1. Open chat history (clock icon in top banner). +2. Select a different conversation or click "New Chat". +3. Open chat history again. +4. Select the conversation from TC-001. + +#### Expected Result +- The subagent card header is visible, positioned between the + `run_subagent` tool-call status and the main agent's summary. +- Subagent tool calls are displayed inside the card (not as flat + labels under the main turn). +- The main agent's summary text appears after the subagent section. + +#### Key Screenshots +- [ ] **Restored conversation** — subagent card with tool calls visible + inside, main agent summary below. + +--- + +### TC-003: Subagent content restored after cancel mid-execution + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- A new conversation in Agent mode. + +#### Steps +1. Send a prompt that triggers subagent execution. +2. While the subagent is running (tool calls streaming), click **Cancel** + (stop button). +3. Open chat history, switch to another conversation, then switch back. + +#### Expected Result +- The subagent card header is visible with partial content. +- Tool calls executed before cancel are displayed inside the card. +- The send button is in the send state. + +#### Key Screenshots +- [ ] **After cancel** — partial subagent card visible, send button reset. +- [ ] **After restore** — same partial content restored correctly. + +--- + +## 3. Progress event isolation + +### TC-004: Subagent progress does not leak when switching mid-execution + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- A new conversation in Agent mode. + +#### Steps +1. Send a prompt that triggers subagent execution. +2. While the subagent is still running, open chat history and switch to + a different conversation. +3. Observe the newly opened conversation. + +#### Expected Result +- The newly opened conversation does not show any subagent output + (tool calls, card headers, or text) from the previous conversation. +- The send button is in the send state (not stuck on cancel). +- No error messages or orphaned widgets appear. +- `workspace.log` should show `conversation/destroy` being sent for + the previous conversation. + +#### Key Screenshots +- [ ] **Switched conversation** — clean UI, no leaked subagent output. + +#### Notes on failure modes +- Subagent output appearing in the wrong conversation → + `isProgressForCurrentConversation()` check failed; verify + `subagentConversationId` is being set on first subagent event and + cleared on cancel/switch. +- Send button stuck on cancel → `onCancel()` did not call + `actionBar.resetSendButton()`. + +--- + +## 4. Multiple subagents in one turn + +### TC-005: Two subagents in a single turn restore correctly + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- A new conversation in Agent mode. + +#### Steps +1. Send a prompt that triggers multiple subagent invocations + (e.g., `invoke two different subagents` or a prompt where the agent + decides to call `run_subagent` twice). +2. Wait for both subagents to complete. +3. Switch to another conversation and back. + +#### Expected Result +- Each subagent has its own card header with the correct agent name. +- Tool calls for each subagent appear under their respective cards. +- After restoration, both subagent cards are correctly positioned and + filled with their respective tool calls. +- The main agent's summary (if any) appears after the last subagent block. + +#### Key Screenshots +- [ ] **Live execution** — two distinct subagent cards visible. +- [ ] **After restore** — both cards restored with correct content. + +#### Notes on failure modes +- Content from subagent A appearing in subagent B's card → + `subagentToolCallId` association is incorrect; check that + `lastRunSubagentToolCallId` is updated for each `run_subagent` + tool call. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java index 66e64742..fab1ada1 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java @@ -27,6 +27,11 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -45,6 +50,7 @@ public abstract class BaseTurnWidget extends Composite { protected SourceViewerComposite currentCodeBlock; protected Map statusLabels; protected SubagentMessageBlock currentSubagentBlock; + protected Map subagentBlocks; // Data protected StringBuilder messageBuffer; @@ -90,6 +96,7 @@ protected BaseTurnWidget(Composite parent, int style, ChatServiceManager service this.turnId = turnId; this.codeBlockIndex = 1; this.statusLabels = new HashMap<>(); + this.subagentBlocks = new HashMap<>(); // editor group // align all children vertically GridLayout gl = new GridLayout(1, true); @@ -292,6 +299,7 @@ private void handleSubagentToolCall(AgentToolCall toolCall) { inSubagentBlock = true; currentSubagentBlock = new SubagentMessageBlock(this, SWT.NONE, serviceManager, toolCall.getId(), toolCall); currentSubagentBlock.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + subagentBlocks.put(toolCall.getId(), currentSubagentBlock); requestLayout(); } break; @@ -302,6 +310,14 @@ private void handleSubagentToolCall(AgentToolCall toolCall) { inSubagentBlock = false; currentSubagentBlock = null; requestLayout(); + } else if (!subagentBlocks.containsKey(toolCall.getId())) { + // Restoration path: create a completed subagent block for later content injection + reset(); + SubagentMessageBlock block = new SubagentMessageBlock(this, SWT.NONE, serviceManager, toolCall.getId(), + toolCall); + block.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + subagentBlocks.put(toolCall.getId(), block); + requestLayout(); } break; case "cancelled": @@ -335,6 +351,54 @@ private void handleSubagentToolCall(AgentToolCall toolCall) { } } + /** + * Restores subagent content into the SubagentMessageBlock identified by the tool call ID. Creates the block if it + * doesn't exist (for restoration from persisted data). Used during conversation history restoration. + * + * @param toolCallId the run_subagent tool call ID + * @param copilotTurn the subagent's CopilotTurnData + * @param dataFactory the factory for converting tool call data + */ + public void restoreSubagentContent(String toolCallId, CopilotTurnData copilotTurn, + ConversationDataFactory dataFactory) { + // Find existing SubagentMessageBlock or create one for restoration + SubagentMessageBlock block = subagentBlocks.get(toolCallId); + if (block == null) { + // Create a minimal block for restoration (the run_subagent tool call may have already been + // rendered with completed status, which closes the block) + block = new SubagentMessageBlock(this, SWT.NONE, serviceManager, toolCallId, null); + block.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + subagentBlocks.put(toolCallId, block); + requestLayout(); + } + + // Append subagent's content into the block + ReplyData replyData = copilotTurn.getReply(); + if (replyData == null) { + return; + } + + if (StringUtils.isNotBlank(replyData.getText())) { + block.appendMessage(replyData.getText()); + } + + if (replyData.getEditAgentRounds() != null) { + for (EditAgentRoundData round : replyData.getEditAgentRounds()) { + if (round.getReply() != null && !round.getReply().isEmpty()) { + block.appendMessage(round.getReply()); + } + if (round.getToolCalls() != null) { + for (ToolCallData toolCallData : round.getToolCalls()) { + AgentToolCall agentToolCall = dataFactory.convertToolCallDataToAgentToolCall(toolCallData); + block.appendToolCallStatus(agentToolCall); + } + } + } + } + + block.notifyTurnEnd(); + } + private void processMessageLine(String line) { SwtUtils.invokeOnDisplayThread(() -> { if (line.trim().startsWith(CODE_BLOCK_ANNOTATION)) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 02966b30..9130891c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -46,6 +46,7 @@ import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound; import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatCreateResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; @@ -110,6 +111,7 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private boolean hasHistory = false; private String conversationId = ""; private String subagentConversationId = null; + private String lastRunSubagentToolCallId = null; private ConversationState conversationState = ConversationState.NEW_CONVERSATION; private Set> conversationFutures = new HashSet<>(); private IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); @@ -804,6 +806,11 @@ public void onChatProgress(ChatProgressValue value) { }); break; case report: + // Ignore progress events from a different conversation + if (!isProgressForCurrentConversation(value)) { + return; + } + // Update context size donut if data is available ContextSizeInfo contextSize = value.getContextSize(); if (contextSize != null) { @@ -830,17 +837,43 @@ public void onChatProgress(ChatProgressValue value) { this.chatContentViewer.processTurnEvent(value); } + // Track run_subagent tool call ID for associating subagent turns + if (StringUtils.isBlank(value.getParentTurnId()) && value.getAgentRounds() != null) { + for (AgentRound round : value.getAgentRounds()) { + if (round.getToolCalls() != null) { + for (AgentToolCall tc : round.getToolCalls()) { + if ("run_subagent".equals(tc.getName())) { + this.lastRunSubagentToolCallId = tc.getId(); + } + } + } + } + } + // If exiting subagent context (no parentTurnId), clear the subagent conversation ID if (StringUtils.isBlank(value.getParentTurnId()) && this.subagentConversationId != null) { this.subagentConversationId = null; + this.lastRunSubagentToolCallId = null; } // Cache conversation progress on report if (persistenceManager != null) { persistenceManager.cacheConversationProgress(this.conversationId, value); + + // Set subagentToolCallId on subagent CopilotTurnData for restoration + if (StringUtils.isNotBlank(value.getParentTurnId()) + && StringUtils.isNotBlank(this.lastRunSubagentToolCallId)) { + persistenceManager.setSubagentToolCallId(this.conversationId, value.getTurnId(), + this.lastRunSubagentToolCallId); + } } break; case end: + // Ignore progress events from a different conversation + if (!isProgressForCurrentConversation(value)) { + return; + } + if (this.chatContentViewer != null) { this.chatContentViewer.processTurnEvent(value); this.actionBar.resetSendButton(); @@ -1051,6 +1084,18 @@ private void onSendInternal(String workDoneToken, String message, String agentSl } } + /** + * Checks if a progress event belongs to the currently displayed conversation. Subagent events (with parentTurnId) + * must match the tracked subagent conversation ID. Non-subagent events must match the main conversation ID. + */ + private boolean isProgressForCurrentConversation(ChatProgressValue value) { + String progressConversationId = value.getConversationId(); + if (StringUtils.isNotBlank(value.getParentTurnId())) { + return StringUtils.equals(progressConversationId, this.subagentConversationId); + } + return StringUtils.equals(progressConversationId, this.conversationId); + } + /** * Align with @Workspace of vscode, because we are actually indexing the whole workspace, not a single project. * (@Project is only for IntelliJ.) @@ -1128,8 +1173,23 @@ private void clearCurrentConversation() { @Override public void onCancel() { + // Destroy the conversation on the server to stop any in-progress processing + if (StringUtils.isNotBlank(this.conversationId)) { + CopilotLanguageServerConnection ls = CopilotCore.getPlugin().getCopilotLanguageServer(); + if (ls != null) { + ls.destroyConversation(this.conversationId); + } + } + if (StringUtils.isNotBlank(this.subagentConversationId)) { + CopilotLanguageServerConnection ls = CopilotCore.getPlugin().getCopilotLanguageServer(); + if (ls != null) { + ls.destroyConversation(this.subagentConversationId); + } + } + // Clear subagent conversation ID on cancel this.subagentConversationId = null; + this.lastRunSubagentToolCallId = null; if (persistenceManager != null && StringUtils.isNotBlank(this.conversationId)) { persistenceManager.persistCachedConversation(this.conversationId); @@ -1138,6 +1198,11 @@ public void onCancel() { future.cancel(false); }); conversationFutures.clear(); + + // Reset send button in case the conversation was cancelled while in-progress + if (this.actionBar != null && !this.actionBar.isDisposed()) { + this.actionBar.resetSendButton(); + } } @Override @@ -1541,6 +1606,23 @@ private void restoreTurn(AbstractTurnData turn) { return; } + // Subagent turns: render their content inside the parent turn's subagent block + if (turn instanceof CopilotTurnData copilotTurn + && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) { + BaseTurnWidget parentWidget = chatContentViewer.getTurnWidget(copilotTurn.getParentTurnId()); + if (parentWidget != null) { + String toolCallId = copilotTurn.getSubagentToolCallId(); + if (StringUtils.isNotBlank(toolCallId)) { + // Restore subagent content into the SubagentMessageBlock identified by the tool call ID + parentWidget.restoreSubagentContent(toolCallId, copilotTurn, persistenceManager.getDataFactory()); + } else { + // Fallback: append to parent widget directly (legacy data without subagentToolCallId) + restoreCopilotTurnContent(copilotTurn, parentWidget); + } + } + return; + } + // Create user turn widget and populate with user message if (turn instanceof UserTurnData userTurn) { if (userTurn.getMessage() == null || StringUtils.isNotBlank(userTurn.getMessage().getText())) { @@ -1551,74 +1633,72 @@ private void restoreTurn(AbstractTurnData turn) { } } else if (turn instanceof CopilotTurnData copilotTurn) { BaseTurnWidget copilotTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true); - ReplyData replyData = copilotTurn.getReply(); + restoreCopilotTurnContent(copilotTurn, copilotTurnWidget); - if (replyData == null) { - return; - } + copilotTurnWidget.notifyTurnEnd(); - if (StringUtils.isNotBlank(replyData.getText())) { - copilotTurnWidget.appendMessage(replyData.getText()); + // Restore model info footer if model name is present + // This must be done AFTER notifyTurnEnd() to ensure footer appears at the bottom + ReplyData replyData = copilotTurn.getReply(); + if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { + renderModelInfoInTurnWidget(turn.getTurnId(), replyData.getModelName(), replyData.getBillingMultiplier()); } + } + } - if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) { - for (EditAgentRoundData round : replyData.getEditAgentRounds()) { - // Append each round's reply text. - if (round.getReply() != null && !round.getReply().isEmpty()) { - copilotTurnWidget.appendMessage(round.getReply()); - } - - // Concatenate tool call statuses from all rounds. - if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { - for (ToolCallData toolCallData : round.getToolCalls()) { - // Convert TurnData.ToolCallData to AgentToolCall - AgentToolCall agentToolCall = persistenceManager.getDataFactory() - .convertToolCallDataToAgentToolCall(toolCallData); - copilotTurnWidget.appendToolCallStatus(agentToolCall); - } - } - } - } + /** + * Restores the content of a CopilotTurnData (reply text, agent rounds, tool calls, errors, agent messages) into the + * given turn widget. Used for both main copilot turns and subagent turns. + */ + private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidget turnWidget) { + ReplyData replyData = copilotTurn.getReply(); + if (replyData == null) { + return; + } - // Restore any error messages widgets from the reply data - if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) { - for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) { - ErrorData errorData = errorMessageData.getError(); - SwtUtils.invokeOnDisplayThread(() -> { - String errorMessage = errorData != null ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg; - int errorCode = errorData != null ? errorData.getCode() : 0; + if (StringUtils.isNotBlank(replyData.getText())) { + turnWidget.appendMessage(replyData.getText()); + } - copilotTurnWidget.createWarnDialog(errorMessage, errorCode); - }, parent); + if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) { + for (EditAgentRoundData round : replyData.getEditAgentRounds()) { + if (round.getReply() != null && !round.getReply().isEmpty()) { + turnWidget.appendMessage(round.getReply()); } - } - - // Restore any agent messages from the reply data - if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) { - for (AgentMessageData agentMessageData : replyData.getAgentMessages()) { - // TODO: We currently only have GitHub Copilot Coding Agent, need to extend for other agents in the future - if (StringUtils.equals(agentMessageData.getAgentSlug(), UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) { - SwtUtils.invokeOnDisplayThread(() -> { - // Create CodingAgentMessageRequestParams from the persisted data - CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams(); - params.setTitle(agentMessageData.getTitle()); - params.setDescription(agentMessageData.getDescription()); - params.setPrLink(agentMessageData.getPrLink()); - params.setConversationId(this.conversationId); - params.setTurnId(turn.getTurnId()); - - copilotTurnWidget.createAgentMessageWidget(params); - }, parent); + if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { + for (ToolCallData toolCallData : round.getToolCalls()) { + AgentToolCall agentToolCall = persistenceManager.getDataFactory() + .convertToolCallDataToAgentToolCall(toolCallData); + turnWidget.appendToolCallStatus(agentToolCall); } } } + } - copilotTurnWidget.notifyTurnEnd(); + if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) { + for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) { + ErrorData errorData = errorMessageData.getError(); + SwtUtils.invokeOnDisplayThread(() -> { + String errorMessage = errorData != null ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg; + int errorCode = errorData != null ? errorData.getCode() : 0; + turnWidget.createWarnDialog(errorMessage, errorCode); + }, parent); + } + } - // Restore model info footer if model name is present - // This must be done AFTER notifyTurnEnd() to ensure footer appears at the bottom - if (StringUtils.isNotBlank(replyData.getModelName())) { - renderModelInfoInTurnWidget(turn.getTurnId(), replyData.getModelName(), replyData.getBillingMultiplier()); + if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) { + for (AgentMessageData agentMessageData : replyData.getAgentMessages()) { + if (StringUtils.equals(agentMessageData.getAgentSlug(), UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) { + SwtUtils.invokeOnDisplayThread(() -> { + CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams(); + params.setTitle(agentMessageData.getTitle()); + params.setDescription(agentMessageData.getDescription()); + params.setPrLink(agentMessageData.getPrLink()); + params.setConversationId(this.conversationId); + params.setTurnId(copilotTurn.getTurnId()); + turnWidget.createAgentMessageWidget(params); + }, parent); + } } } } From 57f41fe151856d2bb0242a0694769d6d1c05c685 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Sat, 9 May 2026 18:04:33 +0800 Subject: [PATCH 2/3] resolve comments --- .../copilot/eclipse/ui/chat/ChatView.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 9130891c..1ffbc3e4 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -858,14 +858,14 @@ public void onChatProgress(ChatProgressValue value) { // Cache conversation progress on report if (persistenceManager != null) { - persistenceManager.cacheConversationProgress(this.conversationId, value); - - // Set subagentToolCallId on subagent CopilotTurnData for restoration - if (StringUtils.isNotBlank(value.getParentTurnId()) - && StringUtils.isNotBlank(this.lastRunSubagentToolCallId)) { - persistenceManager.setSubagentToolCallId(this.conversationId, value.getTurnId(), - this.lastRunSubagentToolCallId); - } + // Chain setSubagentToolCallId after cacheConversationProgress to avoid race condition + // where the subagent CopilotTurnData hasn't been created yet + final String subagentToolCallId = this.lastRunSubagentToolCallId; + persistenceManager.cacheConversationProgress(this.conversationId, value).thenRun(() -> { + if (StringUtils.isNotBlank(value.getParentTurnId()) && StringUtils.isNotBlank(subagentToolCallId)) { + persistenceManager.setSubagentToolCallId(this.conversationId, value.getTurnId(), subagentToolCallId); + } + }); } break; case end: From 2d2f34db5b08c297df6a84a7c59d2617fd62f749 Mon Sep 17 00:00:00 2001 From: xinyi-gong Date: Mon, 11 May 2026 10:19:41 +0800 Subject: [PATCH 3/3] resolve comments --- .../ConversationPersistenceManager.java | 8 +++++--- .../copilot/eclipse/ui/chat/ChatView.java | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java index f171f272..a52a34bc 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java @@ -556,12 +556,14 @@ public ConversationDataFactory getDataFactory() { * @param conversationId the conversation ID * @param subagentTurnId the subagent's turn ID * @param toolCallId the run_subagent tool call ID from the parent turn + * @return a future that completes when the tool call ID has been set */ - public void setSubagentToolCallId(String conversationId, String subagentTurnId, String toolCallId) { + public CompletableFuture setSubagentToolCallId(String conversationId, String subagentTurnId, + String toolCallId) { if (toolCallId == null || subagentTurnId == null) { - return; + return CompletableFuture.completedFuture(null); } - CompletableFuture.runAsync(() -> { + return CompletableFuture.runAsync(() -> { lock.writeLock().lock(); try { ConversationData conversation = getConversationFromCacheOrLoadFromDisk(conversationId); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 1ffbc3e4..c64ee76d 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -858,14 +858,17 @@ public void onChatProgress(ChatProgressValue value) { // Cache conversation progress on report if (persistenceManager != null) { - // Chain setSubagentToolCallId after cacheConversationProgress to avoid race condition - // where the subagent CopilotTurnData hasn't been created yet + final String currentConversationId = this.conversationId; final String subagentToolCallId = this.lastRunSubagentToolCallId; - persistenceManager.cacheConversationProgress(this.conversationId, value).thenRun(() -> { - if (StringUtils.isNotBlank(value.getParentTurnId()) && StringUtils.isNotBlank(subagentToolCallId)) { - persistenceManager.setSubagentToolCallId(this.conversationId, value.getTurnId(), subagentToolCallId); - } - }); + persistenceManager.cacheConversationProgress(currentConversationId, value) + .thenCompose(v -> { + if (StringUtils.isNotBlank(value.getParentTurnId()) + && StringUtils.isNotBlank(subagentToolCallId)) { + return persistenceManager.setSubagentToolCallId( + currentConversationId, value.getTurnId(), subagentToolCallId); + } + return CompletableFuture.completedFuture(null); + }); } break; case end: @@ -1173,7 +1176,7 @@ private void clearCurrentConversation() { @Override public void onCancel() { - // Destroy the conversation on the server to stop any in-progress processing + // Send conversation/destroy to cancel in-progress turns if (StringUtils.isNotBlank(this.conversationId)) { CopilotLanguageServerConnection ls = CopilotCore.getPlugin().getCopilotLanguageServer(); if (ls != null) {