diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java
index 8870e2689e..85660c4b88 100644
--- a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java
+++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java
@@ -32,6 +32,7 @@ public class AguiAdapterConfig {
private final boolean enableReasoning;
private final Duration runTimeout;
private final String defaultAgentId;
+ private final boolean enableToolProgressStream;
private AguiAdapterConfig(Builder builder) {
this.toolMergeMode = builder.toolMergeMode;
@@ -40,6 +41,7 @@ private AguiAdapterConfig(Builder builder) {
this.enableReasoning = builder.enableReasoning;
this.runTimeout = builder.runTimeout;
this.defaultAgentId = builder.defaultAgentId;
+ this.enableToolProgressStream = builder.enableToolProgressStream;
}
/**
@@ -100,6 +102,18 @@ public String getDefaultAgentId() {
return defaultAgentId;
}
+ /**
+ * Check if tool execution progress should be streamed.
+ *
+ *
When enabled, intermediate tool execution events will be converted into
+ * reasoning messages (Thinking UI) to provide real-time feedback to the frontend.
+ *
+ * @return true if tool progress streaming is enabled
+ */
+ public boolean isEnableToolProgressStream() {
+ return enableToolProgressStream;
+ }
+
/**
* Creates a new builder for AguiAdapterConfig.
*
@@ -129,6 +143,7 @@ public static class Builder {
private boolean enableReasoning = false;
private Duration runTimeout = Duration.ofMinutes(10);
private String defaultAgentId;
+ private boolean enableToolProgressStream = false;
/**
* Set the tool merge mode.
@@ -200,6 +215,22 @@ public Builder defaultAgentId(String defaultAgentId) {
return this;
}
+ /**
+ * Set whether to enable tool execution progress stream.
+ *
+ *
When enabled, intermediate progress chunks generated during tool execution
+ * (e.g., download progress, code execution stdout) will be converted into
+ * {@code ReasoningMessage} events. This leverages the frontend's "Thinking" UI
+ * to display real-time feedback without modifying the AG-UI protocol.
+ *
+ * @param enableToolProgressStream true to convert progress events to thinking blocks
+ * @return This builder
+ */
+ public Builder enableToolProgressStream(boolean enableToolProgressStream) {
+ this.enableToolProgressStream = enableToolProgressStream;
+ return this;
+ }
+
/**
* Build the configuration.
*
diff --git a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java
index 2314e72b9d..a3511e6ba6 100644
--- a/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java
+++ b/agentscope-extensions/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java
@@ -102,6 +102,7 @@ public Flux run(RunAgentInput input) {
StreamOptions.builder()
.eventTypes(EventType.ALL)
.incremental(true)
+ .includeActingChunk(config.isEnableToolProgressStream())
.build();
// Track state for event conversion
@@ -147,35 +148,23 @@ private List convertEvent(Event event, EventConversionState state) {
EventType type = event.getType();
if (type == EventType.REASONING || type == EventType.SUMMARY) {
+ String messageId = msg.getId();
+
// Handle reasoning/summary events - convert to text messages and tool calls
for (ContentBlock block : msg.getContent()) {
if (block instanceof TextBlock textBlock) {
String text = textBlock.getText();
if (text != null && !text.isEmpty()) {
- String messageId = msg.getId();
-
// Start message if not started
- if (!state.hasStartedMessage(messageId)) {
+ if (!state.hasStartedTextMessage(messageId)) {
events.add(
new AguiEvent.TextMessageStart(
state.threadId, state.runId, messageId, "assistant"));
- state.startMessage(messageId);
- }
-
- if (!event.isLast()) {
- // In incremental mode, text is already the delta
- events.add(
- new AguiEvent.TextMessageContent(
- state.threadId, state.runId, messageId, text));
- } else {
- // End message if this is the last event
- if (!state.hasEndedMessage(messageId)) {
- events.add(
- new AguiEvent.TextMessageEnd(
- state.threadId, state.runId, messageId));
- state.endMessage(messageId);
- }
+ state.startTextMessage(messageId);
}
+ events.add(
+ new AguiEvent.TextMessageContent(
+ state.threadId, state.runId, messageId, text));
}
} else if (block instanceof ThinkingBlock thinkingBlock) {
// Handle thinking blocks - convert to REASONING_* events (only if enabled)
@@ -183,8 +172,6 @@ private List convertEvent(Event event, EventConversionState state) {
if (config.isEnableReasoning()) {
String thinking = thinkingBlock.getThinking();
if (thinking != null && !thinking.isEmpty()) {
- String messageId = msg.getId();
-
// Start reasoning message if not started
if (!state.hasStartedReasoningMessage(messageId)) {
events.add(
@@ -195,19 +182,9 @@ private List convertEvent(Event event, EventConversionState state) {
"reasoning"));
state.startReasoningMessage(messageId);
}
-
- if (!event.isLast()) {
- // In incremental mode, thinking is already the delta
- events.add(
- new AguiEvent.ReasoningMessageContent(
- state.threadId, state.runId, messageId, thinking));
- } else {
- // End reasoning message if this is the last event
- events.add(
- new AguiEvent.ReasoningMessageEnd(
- state.threadId, state.runId, messageId));
- state.endReasoningMessage(messageId);
- }
+ events.add(
+ new AguiEvent.ReasoningMessageContent(
+ state.threadId, state.runId, messageId, thinking));
}
}
// If reasoning is disabled, ThinkingBlock content is ignored (backward
@@ -219,7 +196,7 @@ private List convertEvent(Event event, EventConversionState state) {
events.add(
new AguiEvent.TextMessageEnd(
state.threadId, state.runId, activeMessageId));
- state.endMessage(activeMessageId);
+ state.endTextMessage(activeMessageId);
}
// End any active reasoning message before starting tool call
@@ -248,7 +225,7 @@ private List convertEvent(Event event, EventConversionState state) {
}
// Emit tool call args if enabled
- if (config.isEmitToolCallArgs() && !event.isLast()) {
+ if (config.isEmitToolCallArgs()) {
String args = toolUse.getContent();
if (args != null && !args.isEmpty()) {
events.add(
@@ -258,7 +235,26 @@ private List convertEvent(Event event, EventConversionState state) {
}
}
}
- } else if (type == EventType.TOOL_RESULT && event.isLast()) {
+
+ // Independent lifecycle check: prevents dropping the final data chunk
+ // and safely handles empty termination packets.
+ if (event.isLast()) {
+ if (state.hasStartedTextMessage(messageId)
+ && !state.hasEndedTextMessage(messageId)) {
+ events.add(
+ new AguiEvent.TextMessageEnd(state.threadId, state.runId, messageId));
+ state.endTextMessage(messageId);
+ }
+ if (config.isEnableReasoning()
+ && state.hasStartedReasoningMessage(messageId)
+ && !state.hasEndedReasoningMessage(messageId)) {
+ events.add(
+ new AguiEvent.ReasoningMessageEnd(
+ state.threadId, state.runId, messageId));
+ state.endReasoningMessage(messageId);
+ }
+ }
+ } else if (type == EventType.TOOL_RESULT) {
// Handle tool results
for (ContentBlock block : msg.getContent()) {
if (block instanceof ToolResultBlock toolResult) {
@@ -267,7 +263,7 @@ private List convertEvent(Event event, EventConversionState state) {
toolCallId = UUID.randomUUID().toString();
}
- String result = extractToolResultText(toolResult);
+ String resultText = extractToolResultText(toolResult);
boolean hasStarted = state.hasStartedToolCall(toolCallId);
if (!hasStarted) {
@@ -281,18 +277,56 @@ private List convertEvent(Event event, EventConversionState state) {
state.startToolCall(toolCallId);
}
- // Ensure ToolCallEnd is emitted to close arguments phase
- events.add(new AguiEvent.ToolCallEnd(state.threadId, state.runId, toolCallId));
+ if (!event.isLast() && resultText != null && !resultText.isEmpty()) {
+ // Handle the progress of tool execution (only if enabled)
+ // (borrowing from the Reasoning block)
+ if (config.isEnableToolProgressStream()) {
+ String progressMsgId = "tool-progress-" + toolCallId;
- events.add(
- new AguiEvent.ToolCallResult(
- state.threadId,
- state.runId,
- toolCallId,
- result,
- "tool",
- msg.getId()));
- state.endToolCall(toolCallId);
+ if (!state.hasStartedReasoningMessage(progressMsgId)) {
+ events.add(
+ new AguiEvent.ReasoningMessageStart(
+ state.threadId,
+ state.runId,
+ progressMsgId,
+ "assistant"));
+ state.startReasoningMessage(progressMsgId);
+ }
+
+ events.add(
+ new AguiEvent.ReasoningMessageContent(
+ state.threadId,
+ state.runId,
+ progressMsgId,
+ resultText));
+ }
+ }
+
+ // Decouple termination logic from content extraction
+ // to ensure the final progress chunk is emitted before closing.
+ if (event.isLast()) {
+ String thinkingMsgId = "tool-progress-" + toolCallId;
+ if (state.hasStartedReasoningMessage(thinkingMsgId)
+ && !state.hasEndedReasoningMessage(thinkingMsgId)) {
+ events.add(
+ new AguiEvent.ReasoningMessageEnd(
+ state.threadId, state.runId, thinkingMsgId));
+ state.endReasoningMessage(thinkingMsgId);
+ }
+
+ // Send real tool call end and result events
+ events.add(
+ new AguiEvent.ToolCallEnd(state.threadId, state.runId, toolCallId));
+ events.add(
+ new AguiEvent.ToolCallResult(
+ state.threadId,
+ state.runId,
+ toolCallId,
+ resultText,
+ "tool",
+ msg.getId()));
+ state.endToolCall(toolCallId);
+ }
}
}
}
@@ -309,9 +343,9 @@ private List convertEvent(Event event, EventConversionState state) {
private Flux finishRun(EventConversionState state) {
List events = new ArrayList<>();
- // End any messages that weren't properly ended
- for (String messageId : state.getStartedMessages()) {
- if (!state.hasEndedMessage(messageId)) {
+ // End any text messages that weren't properly ended
+ for (String messageId : state.getStartedTextMessages()) {
+ if (!state.hasEndedTextMessage(messageId)) {
events.add(new AguiEvent.TextMessageEnd(state.threadId, state.runId, messageId));
}
}
@@ -385,8 +419,8 @@ private String serializeToolArgs(Map input) {
private static class EventConversionState {
final String threadId;
final String runId;
- private final Set startedMessages = new LinkedHashSet<>();
- private final Set endedMessages = new LinkedHashSet<>();
+ private final Set startedTextMessages = new LinkedHashSet<>();
+ private final Set endedTextMessages = new LinkedHashSet<>();
private final Set startedToolCalls = new LinkedHashSet<>();
private final Set endedToolCalls = new LinkedHashSet<>();
private final Set startedReasoningMessages = new LinkedHashSet<>();
@@ -399,24 +433,24 @@ private static class EventConversionState {
this.runId = runId;
}
- boolean hasStartedMessage(String messageId) {
- return startedMessages.contains(messageId);
+ boolean hasStartedTextMessage(String messageId) {
+ return startedTextMessages.contains(messageId);
}
- void startMessage(String messageId) {
- startedMessages.add(messageId);
+ void startTextMessage(String messageId) {
+ startedTextMessages.add(messageId);
currentTextMessageId = messageId;
}
- void endMessage(String messageId) {
- endedMessages.add(messageId);
+ void endTextMessage(String messageId) {
+ endedTextMessages.add(messageId);
if (Objects.equals(messageId, currentTextMessageId)) {
currentTextMessageId = null;
}
}
- boolean hasEndedMessage(String messageId) {
- return endedMessages.contains(messageId);
+ boolean hasEndedTextMessage(String messageId) {
+ return endedTextMessages.contains(messageId);
}
String getCurrentTextMessageId() {
@@ -424,11 +458,11 @@ String getCurrentTextMessageId() {
}
boolean hasActiveTextMessage() {
- return currentTextMessageId != null && !hasEndedMessage(currentTextMessageId);
+ return currentTextMessageId != null && !hasEndedTextMessage(currentTextMessageId);
}
- Set getStartedMessages() {
- return startedMessages;
+ Set getStartedTextMessages() {
+ return startedTextMessages;
}
boolean hasStartedToolCall(String toolCallId) {
diff --git a/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java
index e1ac649a69..7c8d02352a 100644
--- a/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java
+++ b/agentscope-extensions/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java
@@ -269,7 +269,7 @@ void testRunWithStreamingSummaryEvents() {
long contentCount =
events.stream().filter(e -> e instanceof AguiEvent.TextMessageContent).count();
- assertEquals(2, contentCount, "Should stream summary chunks as text deltas");
+ assertEquals(3, contentCount, "Should stream summary chunks as text deltas");
long startCount =
events.stream().filter(e -> e instanceof AguiEvent.TextMessageStart).count();
@@ -758,8 +758,7 @@ void testToolUseBlockWithNullIdGeneratesUUID() {
@Test
void testTextMessageEndWithLastEventDirectly() {
// Test that when the last event contains text blocks and the message hasn't been ended,
- // the TextMessageEnd is emitted through the new hasEndedMessage check
- // This test specifically covers the new code path at lines 153-158
+ // the TextMessageContent is emitted to prevent tail-data loss, followed by TextMessageEnd.
String msgId = "msg-text";
Msg textMsg =
Msg.builder()
@@ -797,10 +796,13 @@ void testTextMessageEndWithLastEventDirectly() {
assertEquals(1, textEndCount, "Should have exactly 1 TextMessageEnd");
// Verify the event sequence
+ assertEquals(5, events.size(), "Should emit 5 events exactly");
assertInstanceOf(AguiEvent.RunStarted.class, events.get(0));
assertInstanceOf(AguiEvent.TextMessageStart.class, events.get(1));
- assertInstanceOf(AguiEvent.TextMessageEnd.class, events.get(2));
- assertInstanceOf(AguiEvent.RunFinished.class, events.get(3));
+ assertInstanceOf(AguiEvent.TextMessageContent.class, events.get(2));
+ assertEquals("Hello world", ((AguiEvent.TextMessageContent) events.get(2)).delta());
+ assertInstanceOf(AguiEvent.TextMessageEnd.class, events.get(3));
+ assertInstanceOf(AguiEvent.RunFinished.class, events.get(4));
}
@Test
@@ -1696,4 +1698,202 @@ void testRunWithNullThinkingBlock() {
!hasReasoningMessageStart,
"Should NOT have ReasoningMessageStart for null thinking");
}
+
+ @Test
+ void testToolResultProgressStreamingAndAccumulation() {
+ // Test that tool intermediate progress is converted to reasoning blocks
+ // and final result is accumulated correctly.
+ AguiAdapterConfig config =
+ AguiAdapterConfig.builder().enableToolProgressStream(true).build();
+ AguiAgentAdapter adapterWithProgress = new AguiAgentAdapter(mockAgent, config);
+
+ // Chunk 1: isLast = false
+ Msg trChunk1 =
+ Msg.builder()
+ .id("msg-tr1")
+ .role(MsgRole.TOOL)
+ .content(
+ ToolResultBlock.builder()
+ .id("tc-1")
+ .output(TextBlock.builder().text("Downloading 50%").build())
+ .build())
+ .build();
+ Event event1 = new Event(EventType.TOOL_RESULT, trChunk1, false);
+
+ // Chunk 2: isLast = true
+ Msg trChunk2 =
+ Msg.builder()
+ .id("msg-tr1")
+ .role(MsgRole.TOOL)
+ .content(
+ ToolResultBlock.builder()
+ .id("tc-1")
+ .output(TextBlock.builder().text(", Done").build())
+ .build())
+ .build();
+ Event event2 = new Event(EventType.TOOL_RESULT, trChunk2, true);
+
+ when(mockAgent.stream(anyList(), any(StreamOptions.class)))
+ .thenReturn(Flux.just(event1, event2));
+
+ RunAgentInput input =
+ RunAgentInput.builder()
+ .threadId("thread-1")
+ .runId("run-1")
+ .messages(List.of(AguiMessage.userMessage("msg-1", "Download it")))
+ .build();
+
+ List events = adapterWithProgress.run(input).collectList().block();
+ assertNotNull(events);
+
+ String expectedThinkingId = "tool-progress-tc-1";
+
+ // Verify fake reasoning block for progress was created and ended
+ boolean hasFakeReasoningStart =
+ events.stream()
+ .filter(e -> e instanceof AguiEvent.ReasoningMessageStart)
+ .anyMatch(
+ e ->
+ expectedThinkingId.equals(
+ ((AguiEvent.ReasoningMessageStart) e).messageId()));
+ assertTrue(hasFakeReasoningStart, "Should emit ReasoningMessageStart for tool progress");
+
+ boolean hasFakeReasoningEnd =
+ events.stream()
+ .filter(e -> e instanceof AguiEvent.ReasoningMessageEnd)
+ .anyMatch(
+ e ->
+ expectedThinkingId.equals(
+ ((AguiEvent.ReasoningMessageEnd) e).messageId()));
+ assertTrue(hasFakeReasoningEnd, "Should emit ReasoningMessageEnd to close tool progress");
+
+ // Verify final accumulated result
+ AguiEvent.ToolCallResult toolResult =
+ events.stream()
+ .filter(e -> e instanceof AguiEvent.ToolCallResult)
+ .map(e -> (AguiEvent.ToolCallResult) e)
+ .findFirst()
+ .orElse(null);
+
+ assertNotNull(toolResult, "Should have ToolCallResult");
+ assertEquals(", Done", toolResult.content(), "Should accumulate all chunks");
+ }
+
+ @Test
+ void testTailEndDataPreservationWhenIsLastIsTrue() {
+ // Test that content in the very last event (isLast=true) is NOT dropped
+ Msg lastMsgWithContent =
+ Msg.builder()
+ .id("msg-text")
+ .role(MsgRole.ASSISTANT)
+ .content(TextBlock.builder().text("Last crucial words.").build())
+ .build();
+
+ // One single event, carries content AND isLast=true
+ Event event = new Event(EventType.REASONING, lastMsgWithContent, true);
+ when(mockAgent.stream(anyList(), any(StreamOptions.class))).thenReturn(Flux.just(event));
+
+ RunAgentInput input =
+ RunAgentInput.builder()
+ .threadId("t1")
+ .runId("r1")
+ .messages(List.of(AguiMessage.userMessage("m1", "Hi")))
+ .build();
+
+ List events = adapter.run(input).collectList().block();
+ assertNotNull(events);
+
+ // Find events associated with this message
+ boolean hasContent =
+ events.stream().anyMatch(e -> e instanceof AguiEvent.TextMessageContent);
+ boolean hasEnd = events.stream().anyMatch(e -> e instanceof AguiEvent.TextMessageEnd);
+
+ assertTrue(hasContent, "Must emit TextMessageContent even if isLast is true");
+ assertTrue(hasEnd, "Must emit TextMessageEnd");
+
+ // Verify Content comes before End
+ int contentIndex =
+ events.indexOf(
+ events.stream()
+ .filter(e -> e instanceof AguiEvent.TextMessageContent)
+ .findFirst()
+ .get());
+ int endIndex =
+ events.indexOf(
+ events.stream()
+ .filter(e -> e instanceof AguiEvent.TextMessageEnd)
+ .findFirst()
+ .get());
+ assertTrue(contentIndex < endIndex, "Content must be emitted before End");
+ }
+
+ @Test
+ void testEmptyContentListClosesActiveLifecycle() {
+ // Test that an empty content list with isLast=true properly closes active states
+ Msg msg1 =
+ Msg.builder()
+ .id("msg-1")
+ .role(MsgRole.ASSISTANT)
+ .content(TextBlock.builder().text("Start ").build())
+ .build();
+ Event event1 = new Event(EventType.REASONING, msg1, false);
+
+ // Empty content, but signals end
+ Msg msg2 =
+ Msg.builder()
+ .id("msg-1")
+ .role(MsgRole.ASSISTANT)
+ .content(List.of()) // Empty content!
+ .build();
+ Event event2 = new Event(EventType.REASONING, msg2, true);
+
+ when(mockAgent.stream(anyList(), any(StreamOptions.class)))
+ .thenReturn(Flux.just(event1, event2));
+
+ RunAgentInput input = RunAgentInput.builder().threadId("t1").runId("r1").build();
+
+ List events = adapter.run(input).collectList().block();
+ assertNotNull(events);
+
+ long endCount = events.stream().filter(e -> e instanceof AguiEvent.TextMessageEnd).count();
+ assertEquals(
+ 1,
+ endCount,
+ "Should successfully close the text message even if last content block is empty");
+ }
+
+ @Test
+ void testToolCallArgsTailEndPreservation() {
+ // Test that tool argument JSON in the final chunk is not dropped
+ Msg toolMsg =
+ Msg.builder()
+ .id("msg-tc1")
+ .role(MsgRole.ASSISTANT)
+ .content(
+ ToolUseBlock.builder()
+ .id("tc-1")
+ .name("tool")
+ .content("}") // The final closing bracket
+ .build())
+ .build();
+
+ // The tool use block comes with isLast=true
+ Event event = new Event(EventType.REASONING, toolMsg, true);
+ when(mockAgent.stream(anyList(), any(StreamOptions.class))).thenReturn(Flux.just(event));
+
+ RunAgentInput input = RunAgentInput.builder().threadId("t1").runId("r1").build();
+
+ List events = adapter.run(input).collectList().block();
+ assertNotNull(events);
+
+ AguiEvent.ToolCallArgs toolArgs =
+ events.stream()
+ .filter(e -> e instanceof AguiEvent.ToolCallArgs)
+ .map(e -> (AguiEvent.ToolCallArgs) e)
+ .findFirst()
+ .orElse(null);
+
+ assertNotNull(toolArgs, "Must emit ToolCallArgs even if event.isLast() is true");
+ assertEquals("}", toolArgs.delta());
+ }
}