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);