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 bbfeaa307 fix(studio): bound user-facing text on code point boundaries 
(#4692)
bbfeaa307 is described below

commit bbfeaa30780d594d4219590d5666a7cc395747ec
Author: btlqql <[email protected]>
AuthorDate: Sat Sep 26 18:48:49 2026 +0800

    fix(studio): bound user-facing text on code point boundaries (#4692)
    
    fix: bound user-facing text on code point boundaries
    
    Every cap that abbreviates user-facing text, or compares a field with the
    width of its varchar column, counted UTF-16 chars:
    
      - InstanceService.boundedCloudImportText and batchFailureMessage cut the
        cloud-import failure details and the batch-delete detail with
        length()/substring();
      - CliAgentProvider.abbreviate cut the CLI failure output at char 500;
      - ClaudeCodeStreamParser.abbreviate cut a failed tool result reason at 
char
        512;
      - MessagePropertyDisplay.abbreviate cut a message property value at char
        1024, and hasOversizedProperty measured the same way;
      - NotificationOutboxService.abbreviate cut the delivery error it records 
as
        last_error at char 1000;
      - InstanceService.requireTextWithin compared a free-text field with its
        column width in chars, so a value whose emoji are two chars each was
        refused even though it fits the column.
    
    A supplementary character is two chars, so a char-based cut can land between
    its high and its low surrogate and keep half of one. A lone surrogate is not
    a code point, has no UTF-8 encoding and is rejected by MySQL utf8mb4, so the
    value that reaches the JSON response, the agent timeline, the message panel
    and the delivery page shows a replacement character instead of the character
    it was cut out of. A varchar column is declared in code points as well, 
which
    is why the same rule also fixes the column caps.
    
    common/util/TextBounds counts and cuts in code points and is now the one
    implementation these sites call - including the two requireTextWithin caps
    merged in #4713 - instead of each keeping a private variant.
    
    Raw user text reaches these sites: an AI prompt and the CLI output that
    quotes it back, a tool result carrying a message body, end-user message
    properties, and the alert content a webhook receiver echoes into its
    rejection reason.
    
    Widened from the original instance-only patch at the maintainer's request,
    which asked for the shared helper in common/util and for the sites that
    routinely see emoji rather than the two InstanceService caps alone.
---
 .../studio/common/util/MessagePropertyDisplay.java | 16 ++---
 .../rocketmq/studio/common/util/TextBounds.java    | 66 ++++++++++++++++++
 .../rocketmq/studio/instance/InstanceService.java  | 22 ++++--
 .../studio/ops/ai/ClaudeCodeStreamParser.java      | 13 ++--
 .../rocketmq/studio/ops/ai/CliAgentProvider.java   |  6 +-
 .../ops/alert/NotificationOutboxService.java       |  5 +-
 .../studio/common/util/TextBoundsTest.java         | 77 +++++++++++++++++++++
 .../studio/instance/InstanceServiceTest.java       | 79 ++++++++++++++++++++++
 .../studio/ops/ai/ClaudeCodeStreamParserTest.java  | 23 +++++++
 .../studio/ops/ai/CliAgentProviderTest.java        | 19 ++++++
 .../ops/alert/NotificationOutboxServiceTest.java   | 42 ++++++++++++
 11 files changed, 343 insertions(+), 25 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/common/util/MessagePropertyDisplay.java
 
b/server/src/main/java/org/apache/rocketmq/studio/common/util/MessagePropertyDisplay.java
index 379987e37..66b8e3f37 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/common/util/MessagePropertyDisplay.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/common/util/MessagePropertyDisplay.java
@@ -80,20 +80,12 @@ public final class MessagePropertyDisplay {
      */
     public static boolean hasOversizedProperty(Map<String, String> properties) 
{
         return properties != null && properties.values().stream()
-                .anyMatch(value -> value != null && codePointCount(value) > 
MAX_PROPERTY_VALUE_CODE_POINTS);
+                .anyMatch(value -> TextBounds.codePointCount(value) > 
MAX_PROPERTY_VALUE_CODE_POINTS);
     }
 
     private static String abbreviate(String value) {
-        if (value == null || codePointCount(value) <= 
MAX_PROPERTY_VALUE_CODE_POINTS) {
-            return value;
-        }
-        // offsetByCodePoints always lands on a code point boundary, so a 
supplementary character is
-        // kept whole or dropped whole, never split into a lone surrogate.
-        int end = value.offsetByCodePoints(0, MAX_PROPERTY_VALUE_CODE_POINTS);
-        return value.substring(0, end) + "...";
-    }
-
-    private static int codePointCount(String value) {
-        return value.codePointCount(0, value.length());
+        // A property value is end-user text, so the cut has to land on a code 
point boundary: it is
+        // always passed through whole or abbreviated with the ellipsis, never 
split mid-character.
+        return TextBounds.truncate(value, MAX_PROPERTY_VALUE_CODE_POINTS, 
"...");
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/common/util/TextBounds.java 
b/server/src/main/java/org/apache/rocketmq/studio/common/util/TextBounds.java
new file mode 100644
index 000000000..7932a3d67
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/common/util/TextBounds.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.common.util;
+
+/**
+ * Bounds the user-facing text this codebase abbreviates and validates.
+ *
+ * <p>Every cap here is a budget of characters, and a UTF-16 {@code char} is 
the wrong unit for it:
+ * a supplementary character (an emoji, a CJK extension character) is two 
chars, so a char-based cut
+ * can land between its high and its low surrogate and keep half of one. A 
lone surrogate is not a
+ * code point, has no UTF-8 encoding and is rejected by MySQL utf8mb4, so the 
half character reaches
+ * the browser as a replacement character instead of the character it was cut 
out of. Counting and
+ * cutting in code points keeps such a character whole or drops it whole.
+ *
+ * <p>A code-point budget is also what a database column width means: MySQL 
counts a {@code varchar}
+ * in characters, not in UTF-16 chars, so 512 emoji fit a {@code varchar(512)}.
+ */
+public final class TextBounds {
+
+    private TextBounds() {
+    }
+
+    /** Number of code points in {@code value}, 0 for null: the unit every cap 
is counted in. */
+    public static int codePointCount(String value) {
+        return value == null ? 0 : value.codePointCount(0, value.length());
+    }
+
+    /**
+     * Longest prefix of {@code value} that fits in {@code maxCodePoints} code 
points, or {@code value}
+     * itself when it already fits. The cut always lands on a code point 
boundary, so a supplementary
+     * character is never split into a lone surrogate.
+     */
+    public static String truncate(String value, int maxCodePoints) {
+        int budget = Math.max(0, maxCodePoints);
+        if (codePointCount(value) <= budget) {
+            return value;
+        }
+        return value.substring(0, value.offsetByCodePoints(0, budget));
+    }
+
+    /**
+     * {@code value} when it fits in {@code maxCodePoints} code points, 
otherwise its
+     * {@link #truncate(String, int) truncated} prefix with {@code suffix} 
appended. The suffix marks a
+     * value that was cut, so it is never appended to one that merely ends the 
same way.
+     */
+    public static String truncate(String value, int maxCodePoints, String 
suffix) {
+        if (codePointCount(value) <= Math.max(0, maxCodePoints)) {
+            return value;
+        }
+        return truncate(value, maxCodePoints) + suffix;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index 04e9f8377..54e56e980 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -28,6 +28,7 @@ import 
org.apache.rocketmq.studio.common.domain.enums.InstanceType;
 import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.util.RegionNames;
+import org.apache.rocketmq.studio.common.util.TextBounds;
 import org.apache.rocketmq.studio.provider.CloudCatalogProvider;
 import org.apache.rocketmq.studio.provider.CloudInstanceDetailVO;
 import org.apache.rocketmq.studio.provider.CloudInstanceOptionVO;
@@ -88,8 +89,10 @@ public class InstanceService {
     static final int COUNT_PARALLELISM = 8;
     static final int COUNT_QUEUE_CAPACITY = 128;
     static final long COUNT_TIMEOUT_SECONDS = 3;
+    /** Caps a batch-delete failure message; counted in code points, not 
UTF-16 chars. */
     private static final int MAX_BATCH_FAILURE_MESSAGE_LENGTH = 500;
     static final int MAX_CLOUD_IMPORT_FAILURE_DETAILS = 100;
+    /** Caps one cloud-import failure detail; counted in code points, not 
UTF-16 chars. */
     static final int MAX_CLOUD_IMPORT_FAILURE_MESSAGE_LENGTH = 500;
 
     private final InstanceResourceCountRunner countRunner = new 
InstanceResourceCountRunner(
@@ -378,10 +381,13 @@ public class InstanceService {
 
     private static String boundedCloudImportText(String value, int maxLength) {
         String singleLine = value == null ? "" : value.replaceAll("\\s+", " 
").trim();
-        if (singleLine.length() <= maxLength) {
+        if (TextBounds.codePointCount(singleLine) <= maxLength) {
             return singleLine;
         }
-        return singleLine.substring(0, maxLength - 1) + "…";
+        // The cut has to land on a code point boundary: a UTF-16 char offset 
can sit between the
+        // two chars of a supplementary character and publish half of it. The 
ellipsis counts
+        // towards the budget, so the prefix keeps one code point less than 
the cap.
+        return TextBounds.truncate(singleLine, maxLength - 1) + "…";
     }
 
     private static final class CloudImportAccumulator {
@@ -577,9 +583,12 @@ public class InstanceService {
      * Bounds a free-text field to the width of its rmq_instance column. 
Letting a longer value
      * through does not store it: MySQL rejects the write, so the caller gets 
a 500 from the
      * persistence layer instead of the validation error the name field 
already returns.
+     *
+     * <p>The width is counted in code points, the unit MySQL counts a {@code 
varchar} in. Counting
+     * UTF-16 chars would reject a value that fits the column because its 
emoji are two chars each.
      */
     private static String requireTextWithin(String value, int maxLength, 
String field) {
-        if (value != null && value.length() > maxLength) {
+        if (TextBounds.codePointCount(value) > maxLength) {
             throw new BusinessException(400, "InstanceVO " + field + " must 
not exceed "
                     + maxLength + " characters");
         }
@@ -749,8 +758,11 @@ public class InstanceService {
             message = failure.getClass().getSimpleName();
         }
         message = message.trim();
-        return message.length() > MAX_BATCH_FAILURE_MESSAGE_LENGTH
-                ? message.substring(0, MAX_BATCH_FAILURE_MESSAGE_LENGTH) : 
message;
+        if (TextBounds.codePointCount(message) <= 
MAX_BATCH_FAILURE_MESSAGE_LENGTH) {
+            return message;
+        }
+        // Cut on a code point boundary so a supplementary character is never 
split in half.
+        return TextBounds.truncate(message, MAX_BATCH_FAILURE_MESSAGE_LENGTH);
     }
 
     private void removeDataSourceBindings(String instanceId) {
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 fa40f9bf6..033a77d5e 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
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.ops.ai;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.common.util.TextBounds;
 import org.apache.rocketmq.studio.ops.ai.conversation.event.AgentEvent;
 import 
org.apache.rocketmq.studio.ops.ai.conversation.event.AgentEventProjector;
 import org.apache.rocketmq.studio.ops.ai.conversation.event.ThinkingSource;
@@ -167,7 +168,7 @@ final class ClaudeCodeStreamParser {
     static final String RAW_INPUT_KEY = "_raw";
 
     /** Ceiling for a short human-readable reason: a tool error, or the 
provider's own message. */
-    private static final int MAX_REASON_CHARS = 512;
+    private static final int MAX_REASON_CODE_POINTS = 512;
 
     /** How many denied tool names a permission-denial notice lists before it 
says "and N more". */
     private static final int MAX_DENIED_NAMES = 5;
@@ -515,7 +516,7 @@ final class ClaudeCodeStreamParser {
             boolean failed = block.path("is_error").asBoolean(false);
             String output = normaliseToolResult(block.path("content"));
             events.add(new AgentEvent.ToolDone(id, displayName(id, null), 
output, !failed, null,
-                    failed ? abbreviate(output, MAX_REASON_CHARS) : null));
+                    failed ? abbreviate(output, MAX_REASON_CODE_POINTS) : 
null));
         }
         return events;
     }
@@ -606,7 +607,7 @@ final class ClaudeCodeStreamParser {
             return Optional.empty();
         }
         return Optional.of(new 
AgentEvent.ProviderNotice(AgentEventProjector.LEVEL_ERROR,
-                "provider reported: " + abbreviate(String.join("; ", 
messages), MAX_REASON_CHARS)));
+                "provider reported: " + abbreviate(String.join("; ", 
messages), MAX_REASON_CODE_POINTS)));
     }
 
     /**
@@ -690,11 +691,13 @@ final class ClaudeCodeStreamParser {
         return value.isNumber() ? value.asInt() : null;
     }
 
-    private static String abbreviate(String value, int maxChars) {
+    private static String abbreviate(String value, int maxCodePoints) {
         if (value == null) {
             return "";
         }
-        return value.length() <= maxChars ? value : value.substring(0, 
maxChars) + "...";
+        // These reasons quote tool results, i.e. message bodies an end user 
wrote, so the cut has to
+        // land on a code point boundary rather than between the two chars of 
an emoji.
+        return TextBounds.truncate(value, maxCodePoints, "...");
     }
 
     /** A tool call whose arguments are still arriving: its id, its sanitised 
name, its buffer. */
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
index a54477f26..6a226f0f5 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.studio.ops.ai;
 
 import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.common.util.TextBounds;
 import org.apache.rocketmq.studio.ops.ai.conversation.agent.CliBinaryProbe;
 import org.springframework.util.StringUtils;
 
@@ -194,8 +195,9 @@ public abstract class CliAgentProvider implements 
AgentProvider {
         if (value == null) {
             return "";
         }
-        String trimmed = value.trim();
-        return trimmed.length() <= 500 ? trimmed : trimmed.substring(0, 500) + 
"...";
+        // The prompt is raw user text and the CLI is free to quote it back on 
failure, so the
+        // failure text this bounds can carry an emoji: cut on a code point 
boundary, not on a char.
+        return TextBounds.truncate(value.trim(), 500, "...");
     }
 
     private static final class OutputLimitException extends IOException {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
index ae48d1f37..4de59f2a7 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxService.java
@@ -17,6 +17,7 @@ import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.cluster.metrics.AlertingProperties;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import 
org.apache.rocketmq.studio.common.util.NoRedirectClientHttpRequestFactory;
+import org.apache.rocketmq.studio.common.util.TextBounds;
 import org.apache.rocketmq.studio.common.util.UrlHostGuard;
 import 
org.apache.rocketmq.studio.persistence.entity.RmqAlertNotificationOutbox;
 import 
org.apache.rocketmq.studio.persistence.mapper.RmqAlertNotificationOutboxMapper;
@@ -539,7 +540,9 @@ public class NotificationOutboxService {
 
     private static String abbreviate(String value) {
         if (value == null) return "Delivery failed";
-        return value.length() > 1000 ? value.substring(0, 1000) : value;
+        // The text comes from outside the codebase - a webhook receiver's 
response body, an SMTP
+        // reply - and those quote the notification content back, so an emoji 
can sit at the cut.
+        return TextBounds.truncate(value, 1000);
     }
 
     private boolean updateClaimed(RmqAlertNotificationOutbox row, String 
claimToken,
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/common/util/TextBoundsTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/common/util/TextBoundsTest.java
new file mode 100644
index 000000000..c99a405ea
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/common/util/TextBoundsTest.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.common.util;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TextBoundsTest {
+
+    /** Emoji is one code point, two UTF-16 chars: the case every bound in 
TextBounds has to survive. */
+    private static final String EMOJI = "\uD83D\uDE00";
+
+    @Test
+    void codePointCountShouldCountASupplementaryCharacterOnceTest() {
+        assertThat(TextBounds.codePointCount(EMOJI)).isEqualTo(1);
+        assertThat(TextBounds.codePointCount("a" + EMOJI + "b")).isEqualTo(3);
+        assertThat(TextBounds.codePointCount("")).isZero();
+        assertThat(TextBounds.codePointCount(null)).isZero();
+    }
+
+    @Test
+    void truncateShouldNotSplitASurrogatePairTest() {
+        // Four code points cut to three: the emoji is the third, so a 
char-based cut would stop on
+        // its high surrogate instead.
+        String value = "ab" + EMOJI + "d";
+
+        String truncated = TextBounds.truncate(value, 3);
+
+        assertThat(truncated).isEqualTo("ab" + EMOJI);
+        assertThat(truncated.codePointCount(0, 
truncated.length())).isEqualTo(3);
+    }
+
+    @Test
+    void truncateShouldKeepAValueThatFitsTest() {
+        String atCap = "a".repeat(3) + EMOJI;
+
+        // Four code points, five chars: a char-based cap would cut one code 
point short here.
+        assertThat(TextBounds.truncate(atCap, 4)).isSameAs(atCap);
+        assertThat(TextBounds.truncate("short", 500)).isEqualTo("short");
+        assertThat(TextBounds.truncate(null, 500)).isNull();
+    }
+
+    @Test
+    void truncateShouldAppendTheSuffixOnlyToACutValueTest() {
+        String value = "x".repeat(4) + EMOJI;
+
+        assertThat(TextBounds.truncate(value, 4, 
"...")).isEqualTo("x".repeat(4) + "...");
+        // The value ends with the same characters as the suffix and is still 
not touched: only a cut
+        // may add the marker, or a value that fits would claim it was 
abbreviated.
+        assertThat(TextBounds.truncate("ab...", 500, 
"...")).isEqualTo("ab...");
+        assertThat(TextBounds.truncate(null, 500, "...")).isNull();
+    }
+
+    @Test
+    void truncateShouldTreatAnEmptyBudgetAsNoTextTest() {
+        assertThat(TextBounds.truncate("ab", 0)).isEmpty();
+        assertThat(TextBounds.truncate("ab", -1)).isEmpty();
+        assertThat(TextBounds.truncate("ab", -1, "...")).isEqualTo("...");
+        // An empty value already fits any budget, so no suffix is invented 
for it.
+        assertThat(TextBounds.truncate("", 0, "...")).isEmpty();
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index bfb8719dd..0de5d3fd1 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -112,6 +112,9 @@ class InstanceServiceTest {
     @InjectMocks
     private InstanceService instanceService;
 
+    /** One code point, two UTF-16 chars: the case the failure-message caps 
have to survive. */
+    private static final String EMOJI = "\uD83D\uDE00";
+
     @org.junit.jupiter.api.BeforeEach
     void registrationGuardFixture() {
         
org.mockito.Mockito.lenient().when(ownershipGuard.withInstanceRegistration(anyString(),
 any()))
@@ -581,6 +584,26 @@ class InstanceServiceTest {
                 .hasMessage("InstanceVO endpoint must not exceed 512 
characters");
     }
 
+    @Test
+    void createInstanceShouldBoundTheEndpointByCodePointsTest() {
+        
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation -> 
invocation.getArgument(0));
+        // 512 code points but 1024 UTF-16 chars. MySQL counts a varchar(512) 
in characters, so the
+        // value fits the column and counting chars would reject it as a 500 
from the write instead.
+        String atLimit = 
EMOJI.repeat(InstanceService.MAX_INSTANCE_ENDPOINT_LENGTH);
+        InstanceVO accepted = 
InstanceVO.builder().name("inst-a").type(InstanceType.PROXY_CLUSTER)
+                .endpoint(atLimit).build();
+
+        
assertThat(instanceService.createInstance(accepted).getEndpoint()).isEqualTo(atLimit);
+
+        InstanceVO rejected = 
InstanceVO.builder().name("inst-b").type(InstanceType.PROXY_CLUSTER)
+                .endpoint(atLimit + EMOJI).build();
+        assertThatThrownBy(() -> instanceService.createInstance(rejected))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("InstanceVO endpoint must not exceed 512 
characters");
+        verify(instanceRepository, never()).save(argThat(instance -> 
instance.getEndpoint() != null
+                && instance.getEndpoint().codePointCount(0, 
instance.getEndpoint().length()) > 512));
+    }
+
     @Test
     void createInstanceShouldBoundTheRemarkToTheColumnWidthTest() {
         
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation -> 
invocation.getArgument(0));
@@ -1936,6 +1959,47 @@ class InstanceServiceTest {
         assertThat(updated.getCloudInstanceId()).isEqualTo("rmq-cn-xxx");
     }
 
+    @Test
+    void deleteInstancesShouldCutFailureMessagesOnCodePointBoundariesTest() {
+        // The 500-char cap lands between the two chars of the emoji, so a 
char based cut
+        // publishes half of it in the batch result.
+        InstanceVO existing = InstanceVO.builder().name("inst-a").build();
+        existing.setId(1L);
+        
when(instanceRepository.findByIdentifier("inst-a")).thenReturn(Optional.of(existing));
+        
when(instanceRepository.findById(1L)).thenReturn(Optional.of(existing));
+        
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
+        when(instanceProvider.countTopics("inst-a"))
+                .thenThrow(new IllegalStateException("x".repeat(499) + EMOJI + 
"tail"));
+        ReflectionTestUtils.setField(instanceService, "self", instanceService);
+
+        BatchDeleteResultVO result = 
instanceService.deleteInstances(List.of("inst-a"));
+
+        assertThat(result.getDeleted()).isZero();
+        assertThat(result.getFailed()).singleElement().satisfies(detail -> {
+            assertThat(hasUnpairedSurrogate(detail)).isFalse();
+            assertThat(detail).isEqualTo("inst-a: " + "x".repeat(499) + EMOJI);
+        });
+    }
+
+    @Test
+    void 
importCloudInstancesShouldBoundFailureMessagesOnCodePointBoundariesTest() {
+        // The detail is "regions: " + the failure message capped at 500 code 
points, and the cap
+        // lands between the two chars of the emoji.
+        CloudCatalogProvider catalog = prepareAliyunCatalog();
+        when(catalog.listRegions(1L))
+                .thenThrow(new IllegalStateException("x".repeat(489) + EMOJI + 
"y".repeat(5)));
+
+        CloudImportResultVO result = 
instanceService.importCloudInstances(InstanceVendor.ALIYUN, 1L);
+
+        assertThat(result.getFailedCount()).isEqualTo(1);
+        assertThat(result.getFailed()).singleElement().satisfies(detail -> {
+            assertThat(hasUnpairedSurrogate(detail)).isFalse();
+            assertThat(detail).isEqualTo("regions: " + "x".repeat(489) + EMOJI 
+ "…");
+            assertThat(detail.codePointCount(0, detail.length()))
+                    
.isEqualTo(InstanceService.MAX_CLOUD_IMPORT_FAILURE_MESSAGE_LENGTH);
+        });
+    }
+
     private CloudCatalogProvider prepareAliyunCatalog() {
         CloudCredentialVO credential = new CloudCredentialVO();
         credential.setId(1L);
@@ -1959,4 +2023,19 @@ class InstanceServiceTest {
         detail.setEndpoints(List.of(new 
CloudInstanceDetailVO.CloudEndpoint("TCP_VPC", endpoint)));
         return detail;
     }
+
+    private static boolean hasUnpairedSurrogate(String value) {
+        for (int index = 0; index < value.length(); index++) {
+            char current = value.charAt(index);
+            if (Character.isHighSurrogate(current)) {
+                if (index + 1 >= value.length() || 
!Character.isLowSurrogate(value.charAt(index + 1))) {
+                    return true;
+                }
+                index++;
+            } else if (Character.isLowSurrogate(current)) {
+                return true;
+            }
+        }
+        return false;
+    }
 }
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 86b536b3c..a41eecafb 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
@@ -104,6 +104,9 @@ class ClaudeCodeStreamParserTest {
     private static final List<String> LINES = loadLines(CAPTURE);
     private static final List<String> MCP_FAILED = 
loadLines(MCP_FAILED_CAPTURE);
 
+    /** One code point, two UTF-16 chars: the case the abbreviation caps have 
to survive. */
+    private static final String EMOJI = "\uD83D\uDE00";
+
     // Synthetic frames: shapes the capture does not contain.
     private static final String TOOL_RESULT_ARRAY_FRAME = """
             
{"type":"user","session_id":"s-1","message":{"role":"user","content":[\
@@ -477,6 +480,26 @@ class ClaudeCodeStreamParserTest {
                 new AgentEvent.ResultMeta(SESSION, 0L, 0, 0, 
"error_during_execution"));
     }
 
+    @Test
+    void aLongFailedToolResultShouldBeAbbreviatedOnCodePointBoundariesTest() {
+        // A tool result quotes a message body, which is end-user text, so an 
emoji can sit on the
+        // 512th char of the reason the user is shown. 511 chars put U+1F600's 
high surrogate exactly
+        // on that cut, and the tail is what the cap has to drop whole.
+        String payload = "x".repeat(511) + EMOJI + "tail";
+        String frame = 
"{\"type\":\"user\",\"session_id\":\"s-1\",\"message\":{\"role\":\"user\","
+                + 
"\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_long\","
+                + "\"is_error\":true,\"content\":\"" + payload + "\"}]}}";
+
+        List<AgentEvent> events = parse(List.of(frame));
+
+        
assertThat(events).singleElement().isInstanceOfSatisfying(AgentEvent.ToolDone.class,
 done -> {
+            assertThat(done.success()).isFalse();
+            // A char-based cut would keep the high surrogate on its own and 
turn it into a
+            // replacement character once the event reaches the timeline.
+            assertThat(done.error()).isEqualTo("x".repeat(511) + EMOJI + 
"...");
+        });
+    }
+
     @Test
     void permissionDenialsShouldSurfaceAsAWarningTest() {
         List<AgentEvent> events = parse(List.of(RESULT_DENIED_FRAME));
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
index 6f2579ebe..4259bc28f 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
@@ -146,6 +146,25 @@ class CliAgentProviderTest {
         assertThat(cli.complete(null, "prompt", null)).isNotEmpty();
     }
 
+    @Test
+    void aFailedCliShouldAbbreviateItsOutputOnCodePointBoundariesTest() {
+        // The CLI quotes the prompt back when it fails, and the prompt is raw 
user text, so an emoji
+        // can sit on the 500th char. The script writes exactly that: 499 
bytes of 'x', then the
+        // 4 UTF-8 bytes of U+1F600, then a tail that the cut drops.
+        String script = "head -c 499 /dev/zero | tr '\\000' x; "
+                + "printf '\\360\\237\\230\\200'; printf tail; exit 1";
+        FakeCli cli = new FakeCli(script);
+
+        assertThatThrownBy(() -> cli.complete(null, "prompt", null))
+                .isInstanceOfSatisfying(LlmGatewayException.class, exception 
-> {
+                    assertThat(exception.getStatusCode()).isEqualTo(502);
+                    
assertThat(exception.getCode()).isEqualTo("llm.provider.cli_error");
+                    // A char-based cut would keep the high surrogate alone 
and drop the low one.
+                    assertThat(exception.getMessage())
+                            .isEqualTo("sh CLI failed: " + "x".repeat(499) + 
"\uD83D\uDE00" + "...");
+                });
+    }
+
     @Test
     void completeRejectsOversizedPromptBeforeStartingCli() {
         FakeCli cli = new FakeCli("echo should-not-run");
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
index 3b949ddd6..e0f120bcd 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/NotificationOutboxServiceTest.java
@@ -293,6 +293,48 @@ class NotificationOutboxServiceTest {
                 "alertId=9, channel=dingtalk", "SUCCESS", null);
     }
 
+    @Test
+    void abbreviatesALongWebhookRejectionReasonOnCodePointBoundariesTest() {
+        RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);
+        SettingsRepository settings = mock(SettingsRepository.class);
+        AlertRepository alerts = mock(AlertRepository.class);
+        OperationAuditService audit = mock(OperationAuditService.class);
+        RestTemplate client = new RestTemplate();
+        MockRestServiceServer server = 
MockRestServiceServer.bindTo(client).build();
+        RmqAlertNotificationOutbox row = new RmqAlertNotificationOutbox();
+        row.setId(8L);
+        row.setAlertId(9L);
+        row.setChannel("dingtalk");
+        row.setStatus("PENDING");
+        row.setAttemptCount(0);
+        String prefix = "DingTalk rejected webhook: ";
+        // The receiver supplies this text and quotes the notification back at 
Studio, and the
+        // notification body carries the alert's own title and labels: an 
emoji reaches the cut.
+        // 999 - prefix.length() chars leave U+1F600's high surrogate exactly 
on the 1000th char.
+        String reason = "x".repeat(999 - prefix.length()) + "\uD83D\uDE00" + 
"tail";
+        when(mapper.findDispatchable(any(LocalDateTime.class), 
any(LocalDateTime.class), any(Integer.class)))
+                .thenReturn(List.of(row));
+        when(mapper.claimForDispatch(any(), any(LocalDateTime.class), 
any(LocalDateTime.class),
+                any(LocalDateTime.class), anyString())).thenReturn(1);
+        when(mapper.update(any(), any())).thenReturn(1);
+        
when(alerts.findAlertById(9L)).thenReturn(Optional.of(SystemAlertVO.builder().id(9L)
+                
.level(AlertLevel.warning).title("Lag").description("high").instanceId("local").build()));
+        
when(settings.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+                .dingtalkWebhook("https://example.com/hook";).build());
+        server.expect(once(), requestTo("https://example.com/hook";))
+                .andRespond(withSuccess("{\"errcode\":310000,\"errmsg\":\"" + 
reason + "\"}",
+                        MediaType.APPLICATION_JSON));
+
+        new NotificationOutboxService(mapper, settings, 
mock(AlertSilenceService.class), alerts, audit, client).dispatch();
+
+        server.verify();
+        // A char-based cut would keep the high surrogate on its own, so the 
audit entry - and the
+        // last_error the delivery page renders - would show a replacement 
character instead.
+        verify(audit).record("RETRY_ALERT_NOTIFICATION", "ALERT_NOTIFICATION", 
"8", null,
+                "alertId=9, channel=dingtalk", "RETRYING",
+                prefix + "x".repeat(999 - prefix.length()) + "\uD83D\uDE00");
+    }
+
     @Test
     void retriesDingTalkDeliveryWhenTheRobotRejectsThePayloadTest() {
         RmqAlertNotificationOutboxMapper mapper = 
mock(RmqAlertNotificationOutboxMapper.class);

Reply via email to