This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 0c2e15ace fix(ai): scope Claude text deduplication to the streamed 
block (#6682)
0c2e15ace is described below

commit 0c2e15ace5406353af9616ff2dcf2b6c802ad1d6
Author: 健康宝 <[email protected]>
AuthorDate: Thu Oct 15 19:08:15 2026 +0800

    fix(ai): scope Claude text deduplication to the streamed block (#6682)
---
 .../studio/ops/ai/ClaudeCodeStreamParser.java      | 64 +++++++++++++++--
 .../studio/ops/ai/ClaudeCodeStreamParserTest.java  | 81 ++++++++++++++++++++++
 2 files changed, 139 insertions(+), 6 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParser.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParser.java
index 033a77d5e..6bc4e0f7e 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParser.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParser.java
@@ -210,12 +210,28 @@ final class ClaudeCodeStreamParser {
     /** Upstream types already reported as unhandled, so each is reported at 
most once per run. */
     private final Set<String> reportedUnhandled = new LinkedHashSet<>();
 
-    /** True once a {@code text_delta} arrived: the aggregated assistant text 
is then a duplicate. */
+    /** True once a {@code text_delta} arrived: fallback for captures whose 
frames carry no
+     *  {@code message.id}, where per-message discrimination is impossible. */
     private boolean textDeltaSeen;
 
     /** Same guard for reasoning. */
     private boolean thinkingDeltaSeen;
 
+    /** {@code message.id}s that streamed a non-empty {@code text_delta}: 
their aggregated
+     *  assistant text is a duplicate. A different message's aggregate is not. 
*/
+    private final Set<String> streamedTextMessageIds = new LinkedHashSet<>();
+
+    /** Same guard for reasoning blocks. */
+    private final Set<String> streamedThinkingMessageIds = new 
LinkedHashSet<>();
+
+    /** The message currently being streamed, from the inner {@code 
message_start}; null between. */
+    private String currentStreamingMessageId;
+
+    /** {@code message.id}s announced by an inner {@code message_start}: only 
for these can a
+     *  delta be attributed with certainty. Captures that never show a {@code 
message_start} fall
+     *  back to the run-level flags. */
+    private final Set<String> observedMessageIds = new LinkedHashSet<>();
+
     /** True once any {@link AgentEvent.TextDelta} was emitted, from either 
source. */
     private boolean textEmitted;
 
@@ -363,7 +379,18 @@ final class ClaudeCodeStreamParser {
             case "content_block_start" -> onContentBlockStart(event);
             case "content_block_delta" -> onContentBlockDelta(event);
             case "content_block_stop" -> onContentBlockStop(event);
-            case "message_start", "message_delta", "message_stop", "ping" -> 
List.of();
+            case "message_start" -> {
+                // The boundary of a new message: remember which one is being
+                // streamed so a delta can be attributed to exactly it.
+                currentStreamingMessageId = 
event.path("message").path("id").asText("");
+                if (StringUtils.hasText(currentStreamingMessageId)) {
+                    observedMessageIds.add(currentStreamingMessageId);
+                } else {
+                    currentStreamingMessageId = null;
+                }
+                yield List.of();
+            }
+            case "message_delta", "message_stop", "ping" -> List.of();
             default -> unhandled(STREAM_EVENT + "/" + eventType);
         };
     }
@@ -393,20 +420,28 @@ final class ClaudeCodeStreamParser {
         String deltaType = delta.path("type").asText("");
         return switch (deltaType) {
             case DELTA_TEXT -> {
-                textDeltaSeen = true;
                 String text = delta.path("text").asText("");
                 if (text.isEmpty()) {
+                    // An empty delta proves nothing was streamed: suppressing
+                    // the aggregate on its account dropped real text.
                     yield List.of();
                 }
+                textDeltaSeen = true;
+                if (currentStreamingMessageId != null) {
+                    streamedTextMessageIds.add(currentStreamingMessageId);
+                }
                 textEmitted = true;
                 yield List.of(new AgentEvent.TextDelta(text));
             }
             case DELTA_THINKING -> {
-                thinkingDeltaSeen = true;
                 String thinking = delta.path("thinking").asText("");
                 if (thinking.isEmpty()) {
                     yield List.of();
                 }
+                thinkingDeltaSeen = true;
+                if (currentStreamingMessageId != null) {
+                    streamedThinkingMessageIds.add(currentStreamingMessageId);
+                }
                 yield List.of(new AgentEvent.ThinkingDelta(thinking, 
ThinkingSource.MODEL));
             }
             case DELTA_INPUT_JSON -> {
@@ -456,7 +491,18 @@ final class ClaudeCodeStreamParser {
             String blockType = block.path("type").asText("");
             switch (blockType) {
                 case BLOCK_TEXT -> {
-                    if (textDeltaSeen) {
+                    // Deduplicate only the same message's streamed text: a
+                    // run-level flag dropped every later message's aggregate
+                    // once any earlier message had streamed. When the capture
+                    // never showed a message_start for this id, attribution
+                    // is impossible and the run-level flag stays the rule.
+                    String messageId = 
node.path("message").path("id").asText("");
+                    boolean attributionKnown = StringUtils.hasText(messageId)
+                            && observedMessageIds.contains(messageId);
+                    boolean streamedAlready = attributionKnown
+                            ? streamedTextMessageIds.contains(messageId)
+                            : textDeltaSeen;
+                    if (streamedAlready) {
                         continue;
                     }
                     String text = block.path("text").asText("");
@@ -466,7 +512,13 @@ final class ClaudeCodeStreamParser {
                     }
                 }
                 case BLOCK_THINKING -> {
-                    if (thinkingDeltaSeen) {
+                    String messageId = 
node.path("message").path("id").asText("");
+                    boolean attributionKnown = StringUtils.hasText(messageId)
+                            && observedMessageIds.contains(messageId);
+                    boolean streamedAlready = attributionKnown
+                            ? streamedThinkingMessageIds.contains(messageId)
+                            : thinkingDeltaSeen;
+                    if (streamedAlready) {
                         continue;
                     }
                     String thinking = block.path("thinking").asText("");
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParserTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParserTest.java
index a41eecafb..95ee62e46 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParserTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParserTest.java
@@ -416,6 +416,87 @@ class ClaudeCodeStreamParserTest {
     }
 
     /** The content of every event of one type, concatenated in arrival order. 
*/
+    @Test
+    void aggregateTextSurvivesAcrossMessagesTest() {
+        // Message one streams its text; message two only aggregates. The old
+        // run-level flag treated every later aggregate as a duplicate of the
+        // earlier stream and dropped it.
+        ClaudeCodeStreamParser parser = new ClaudeCodeStreamParser();
+        List<AgentEvent> events = new ArrayList<>();
+        for (String frame : new String[] {
+            """
+            
{"type":"stream_event","session_id":"s-1","event":{"type":"message_start","message":{"id":"msg_1"}}}""",
+            """
+            
{"type":"stream_event","session_id":"s-1","event":{"type":"content_block_delta","index":0,\
+            "delta":{"type":"text_delta","text":"first"}}}""",
+            """
+            
{"type":"assistant","session_id":"s-1","message":{"id":"msg_1","role":"assistant",\
+            "content":[{"type":"text","text":"first"}]}}""",
+            """
+            
{"type":"stream_event","session_id":"s-1","event":{"type":"message_start","message":{"id":"msg_2"}}}""",
+            """
+            
{"type":"assistant","session_id":"s-1","message":{"id":"msg_2","role":"assistant",\
+            "content":[{"type":"text","text":"second"}]}}"""}) {
+            events.addAll(parser.parseLine(frame));
+        }
+        assertThat(joined(events, 
AgentEvent.TextDelta.class)).isEqualTo("firstsecond");
+    }
+
+    @Test
+    void anEmptyDeltaDoesNotSuppressTheAggregateTest() {
+        ClaudeCodeStreamParser parser = new ClaudeCodeStreamParser();
+        List<AgentEvent> events = new ArrayList<>();
+        for (String frame : new String[] {
+            """
+            
{"type":"stream_event","session_id":"s-1","event":{"type":"message_start","message":{"id":"msg_1"}}}""",
+            """
+            
{"type":"stream_event","session_id":"s-1","event":{"type":"content_block_delta","index":0,\
+            "delta":{"type":"text_delta","text":""}}}""",
+            """
+            
{"type":"assistant","session_id":"s-1","message":{"id":"msg_1","role":"assistant",\
+            "content":[{"type":"text","text":"real aggregate"}]}}"""}) {
+            events.addAll(parser.parseLine(frame));
+        }
+        assertThat(joined(events, AgentEvent.TextDelta.class)).isEqualTo("real 
aggregate");
+    }
+
+    @Test
+    void sameMessageAggregateIsStillDeduplicatedTest() {
+        ClaudeCodeStreamParser parser = new ClaudeCodeStreamParser();
+        List<AgentEvent> events = new ArrayList<>();
+        for (String frame : new String[] {
+            """
+            
{"type":"stream_event","session_id":"s-1","event":{"type":"message_start","message":{"id":"msg_1"}}}""",
+            """
+            
{"type":"stream_event","session_id":"s-1","event":{"type":"content_block_delta","index":0,\
+            "delta":{"type":"text_delta","text":"first"}}}""",
+            """
+            
{"type":"assistant","session_id":"s-1","message":{"id":"msg_1","role":"assistant",\
+            "content":[{"type":"text","text":"first"}]}}"""}) {
+            events.addAll(parser.parseLine(frame));
+        }
+        assertThat(joined(events, 
AgentEvent.TextDelta.class)).isEqualTo("first");
+    }
+
+    @Test
+    void idlessCapturesKeepTheRunLevelFallbackTest() {
+        // Captures without message ids cannot discriminate per message: the
+        // aggregate of the streamed text stays suppressed run-wide, exactly
+        // as before.
+        ClaudeCodeStreamParser parser = new ClaudeCodeStreamParser();
+        List<AgentEvent> events = new ArrayList<>();
+        for (String frame : new String[] {
+            """
+            
{"type":"stream_event","session_id":"s-1","event":{"type":"content_block_delta","index":0,\
+            "delta":{"type":"text_delta","text":"first"}}}""",
+            """
+            
{"type":"assistant","session_id":"s-1","message":{"role":"assistant",\
+            "content":[{"type":"text","text":"first"}]}}"""}) {
+            events.addAll(parser.parseLine(frame));
+        }
+        assertThat(joined(events, 
AgentEvent.TextDelta.class)).isEqualTo("first");
+    }
+
     private static String joined(List<AgentEvent> events, Class<?> type) {
         StringBuilder joined = new StringBuilder();
         for (AgentEvent event : events) {

Reply via email to