Skip to content
Open
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,88 @@
// 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 static org.junit.jupiter.api.Assertions.assertNull;

import java.lang.reflect.Method;
import java.util.List;

import org.junit.jupiter.api.Test;

/**
* Unit tests for the private static parsing helpers in {@link ThinkingBlock}: {@code stripTrailingNewlines} and
* {@code parseSections}. Exercises the helpers via reflection so the production visibility stays untouched.
*
* <p>Primary goal: guard CRLF handling so a {@code \r\n}-terminated body or title boundary does not leak a stray
* {@code \r} into the rendered text.
*/
class ThinkingBlockParseTest {

@Test
void stripTrailingNewlines_stripsLfCrAndCrlf() throws Exception {
assertEquals("body", invokeStripTrailingNewlines("body\n"));
assertEquals("body", invokeStripTrailingNewlines("body\r"));
assertEquals("body", invokeStripTrailingNewlines("body\r\n"));
assertEquals("body", invokeStripTrailingNewlines("body\r\n\r\n"));
assertEquals("body", invokeStripTrailingNewlines("body\n\r\n"));
assertEquals("body", invokeStripTrailingNewlines("body"));
assertEquals("", invokeStripTrailingNewlines(""));
assertEquals("", invokeStripTrailingNewlines("\r\n\r\n"));
// Internal newlines and leading whitespace must survive untouched.
assertEquals(" code\n more", invokeStripTrailingNewlines(" code\n more\r\n"));
}

@Test
void parseSections_handlesCrlfTitleBoundary() throws Exception {
String raw = "intro body\r\n**Plan**\r\nplan body\r\n**Next**\r\nnext body\r\n";
List<?> sections = invokeParseSections(raw);
assertEquals(3, sections.size());

assertNull(title(sections.get(0)));
assertEquals("intro body", body(sections.get(0)));

assertEquals("Plan", title(sections.get(1)));
assertEquals("plan body", body(sections.get(1)));

assertEquals("Next", title(sections.get(2)));
assertEquals("next body", body(sections.get(2)));
}

@Test
void parseSections_handlesLfOnlyTitleBoundary() throws Exception {
// Existing LF behavior must remain unchanged.
String raw = "intro\n**Plan**\nplan body";
List<?> sections = invokeParseSections(raw);
assertEquals(2, sections.size());
assertNull(title(sections.get(0)));
assertEquals("intro", body(sections.get(0)));
assertEquals("Plan", title(sections.get(1)));
assertEquals("plan body", body(sections.get(1)));
}

private static String invokeStripTrailingNewlines(String input) throws Exception {
Method m = ThinkingBlock.class.getDeclaredMethod("stripTrailingNewlines", String.class);
m.setAccessible(true);
return (String) m.invoke(null, input);
}

private static List<?> invokeParseSections(String raw) throws Exception {
Method m = ThinkingBlock.class.getDeclaredMethod("parseSections", String.class);
m.setAccessible(true);
return (List<?>) m.invoke(null, raw);
}

private static String title(Object parsedSection) throws Exception {
Method m = parsedSection.getClass().getDeclaredMethod("title");
m.setAccessible(true);
return (String) m.invoke(parsedSection);
}

private static String body(Object parsedSection) throws Exception {
Method m = parsedSection.getClass().getDeclaredMethod("body");
m.setAccessible(true);
return (String) m.invoke(parsedSection);
}
}
12 changes: 4 additions & 8 deletions com.microsoft.copilot.eclipse.ui/css/dark.css
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,10 @@
background-color: #2F3030;
}

#chat-content-viewer > Composite > CopilotTurnWidget > AgentStatusLabel > .text-secondary {
color: #A4A4A4;
}

#chat-content-viewer > Composite > CopilotTurnWidget > AgentToolCancelLabel > Label.text-secondary {
color: #A4A4A4;
}

#chat-content-viewer > Composite > CopilotTurnWidget > AgentStatusLabel > .text-secondary,
#chat-content-viewer > Composite > CopilotTurnWidget > ThinkingBlock .text-secondary,
#chat-content-viewer > Composite > CopilotTurnWidget > .subagent-message-block ThinkingBlock .text-secondary,
#chat-content-viewer > Composite > CopilotTurnWidget > AgentToolCancelLabel > Label.text-secondary,
#chat-content-viewer > Composite > CopilotTurnWidget > Composite > Label.model-info-label {
color: #A4A4A4;
}
Expand Down
12 changes: 4 additions & 8 deletions com.microsoft.copilot.eclipse.ui/css/light.css
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,10 @@
background-color: #F1F1F2;
}

#chat-content-viewer > Composite > CopilotTurnWidget > AgentStatusLabel > .text-secondary {
color: #808080;
}

#chat-content-viewer > Composite > CopilotTurnWidget > AgentToolCancelLabel > Label.text-secondary {
color: #808080;
}

#chat-content-viewer > Composite > CopilotTurnWidget > AgentStatusLabel > .text-secondary,
#chat-content-viewer > Composite > CopilotTurnWidget > ThinkingBlock .text-secondary,
#chat-content-viewer > Composite > CopilotTurnWidget > .subagent-message-block ThinkingBlock .text-secondary,
#chat-content-viewer > Composite > CopilotTurnWidget > AgentToolCancelLabel > Label.text-secondary,
#chat-content-viewer > Composite > CopilotTurnWidget > Composite > Label.model-info-label {
color: #808080;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,16 @@ protected BaseTurnWidget(Composite parent, int style, ChatServiceManager service
IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class);
this.cancelMsgEventHandler = event -> {
cancelToolConfirmation();
onChatMessageCancelled();
};
eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_MESSAGE_CANCELLED, cancelMsgEventHandler);
}

/** Hook invoked when the chat message cancel event is broadcast. Default no-op; subclasses may override. */
protected void onChatMessageCancelled() {
// no-op
}

public String getTurnId() {
return turnId;
}
Expand Down Expand Up @@ -523,7 +529,8 @@ private void reset() {
this.currentTextBlock = null;
this.inCodeBlock = false;

// Don't reset subagent block state here - it's managed by tool call status
// Subagent and thinking blocks have their own lifecycle (tool call status / ThinkingTurnWidget)
// and are intentionally not reset here.
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@ public void processTurnEvent(ChatProgressValue value) {
ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager();

if (value.getKind() == WorkDoneProgressKind.report) {
if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) {
thinkingTurn.appendThinking(value.getThinking());
if (hasRenderableOutput(value)) {
// Seal before appending the reply so the spinner stops and the title is fetched.
thinkingTurn.sealThinking();
Comment on lines +185 to +187
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q:

the thinking will be sealed when WorkDoneProgressKind.end comes, why do we need this logic?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the mid-turn seal to kick off the title fetch and prevent subsequent thinking stream from being appended to the same block (a new block will be opened instead).

The WorkDoneProgressKind.end seal is a safety net for turns where the mid-turn seal never fired(ex. the model only produced thinking with no renderable output), so the block doesn't stay spinning forever.

}
}

if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) {
// Handle agent mode responses
AgentRound agentRound = value.getAgentRounds().get(0);
Expand All @@ -200,6 +208,10 @@ public void processTurnEvent(ChatProgressValue value) {
turnWidget.appendMessage(value.getReply());
}
} else if (value.getKind() == WorkDoneProgressKind.end) {
// Seal any in-progress thinking block before the turn ends.
if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) {
thinkingTurn.sealThinking();
}
turnWidget.notifyTurnEnd();
}
refreshScrollerLayout();
Expand Down Expand Up @@ -259,6 +271,29 @@ public void appendMessageToTheLatestTurn(String message) {
}
}

/**
* Whether {@code value} carries reply text or an agent round with rendered content; thinking-only reports return
* {@code false} so the banner keeps streaming.
*/
private static boolean hasRenderableOutput(ChatProgressValue value) {
return StringUtils.isNotBlank(value.getReply()) || hasRenderableAgentRound(value);
}

private static boolean hasRenderableAgentRound(ChatProgressValue value) {
if (value.getAgentRounds() == null || value.getAgentRounds().isEmpty()) {
return false;
}
for (AgentRound round : value.getAgentRounds()) {
if (StringUtils.isNotBlank(round.getReply())) {
return true;
}
if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) {
return true;
}
}
return false;
}

/**
* Process todo list from tool call result. Extracts todo list data from the tool-specific data
* and updates the TodoListService.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,8 @@ public void onChatProgress(ChatProgressValue value) {
}
}
if ((value.getAgentRounds() == null || value.getAgentRounds().isEmpty())
&& (value.getReply() == null || value.getReply().isEmpty())) {
&& (value.getReply() == null || value.getReply().isEmpty())
&& (value.getThinking() == null || StringUtils.isBlank(value.getThinking().text()))) {
return;
}
if (this.chatContentViewer != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@
/**
* A custom widget that displays a turn for the copilot.
*/
public class CopilotTurnWidget extends BaseTurnWidget {
public class CopilotTurnWidget extends ThinkingTurnWidget {
/**
* Create the widget.
*/
public CopilotTurnWidget(Composite parent, int style, ChatServiceManager serviceManager, String turnId) {
super(parent, style, serviceManager, turnId, true, null);
super(parent, style, serviceManager, turnId, null);
setData("org.eclipse.swtbot.widget.key", "copilot-turn");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ public final class Messages extends NLS {
public static String todoList_clearButtonDisabled;
public static String todoList_expandTooltip;
public static String todoList_collapseTooltip;
public static String thinking_inProgressTitle;
public static String thinking_cancelledTitle;
public static String thinking_expandTooltip;
public static String thinking_collapseTooltip;

static {
// initialize resource bundle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class SubagentMessageBlock extends Composite {
private AgentToolCall toolCall;

// Track the current content widget for message processing
private BaseTurnWidget currentSubagentTurnWidget;
private ThinkingTurnWidget currentSubagentTurnWidget;

/**
* Create the subagent message block.
Expand Down Expand Up @@ -102,7 +102,7 @@ public void notifyTurnEnd() {
*
* @return the subagent turn widget
*/
public BaseTurnWidget getSubagentTurnWidget() {
public ThinkingTurnWidget getSubagentTurnWidget() {
return currentSubagentTurnWidget;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@
* A turn widget for displaying subagent messages within a SubagentMessageBlock.
* This widget doesn't show an avatar or role name, only the message content.
*/
public class SubagentTurnWidget extends BaseTurnWidget {
public class SubagentTurnWidget extends ThinkingTurnWidget {

/**
* Create the widget.
*/
public SubagentTurnWidget(Composite parent, int style, ChatServiceManager serviceManager, String turnId,
AgentToolCall toolCall) {
super(parent, style, serviceManager, turnId + "_subagent", true,
super(parent, style, serviceManager, turnId + "_subagent",
getToolCallRoleName(toolCall));
}

Expand Down
Loading
Loading