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 305c95c0 feat(ai): persist LLM generation settings and add message 
query/trace tools (#1931)
305c95c0 is described below

commit 305c95c0565e3ef2adf50c0fa62abc6ff1fb69a5
Author: yyqdbngt <[email protected]>
AuthorDate: Fri Aug 14 11:33:52 2026 +0800

    feat(ai): persist LLM generation settings and add message query/trace tools 
(#1931)
    
    Consolidates #1915, #1931, #1939: persist LLM maxTokens/temperature in
    general settings, add message query and trace AI tools, bound the LLM
    gateway executor with a caller-runs pool, and normalize gateway engine
    identifiers with Locale.ROOT.
    
    Co-authored-by: yyqdbngt <[email protected]>
---
 .../rocketmq/studio/ops/ai/LlmConfigService.java   |   6 +-
 .../studio/ops/ai/OpenAiCompatibleLlmGateway.java  |  15 ++-
 .../ops/ai/tool/MessageQueryToolHandler.java       |  94 +++++++++++++++
 .../ops/ai/tool/MessageTraceToolHandler.java       |  89 ++++++++++++++
 .../studio/settings/GeneralSettingsVO.java         |   2 +
 .../src/main/resources/tool-catalog/rmq-tools.yaml | 128 +++++++++++++++++++++
 .../rocketmq/studio/ops/ai/AiEngineLocaleTest.java |  55 +++++++++
 .../studio/ops/ai/LlmConfigServiceTest.java        |   4 +
 .../ops/ai/tool/MessageQueryToolHandlerTest.java   |  88 ++++++++++++++
 .../ops/ai/tool/MessageTraceToolHandlerTest.java   |  86 ++++++++++++++
 .../studio/ops/ai/tool/ToolCatalogTest.java        |   2 +
 .../studio/ops/ai/tool/ToolGatewayServiceTest.java |  29 ++++-
 12 files changed, 588 insertions(+), 10 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
index e2b9ad66..d5666b63 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
@@ -117,6 +117,8 @@ public class LlmConfigService {
                 .deploymentName(normalized.getDeploymentName())
                 .apiVersion(normalized.getApiVersion())
                 .awsRegion(normalized.getAwsRegion())
+                .maxTokens(normalized.getMaxTokens())
+                .temperature(normalized.getTemperature())
                 .build();
         LlmConfigVO nextOverrides = copy(normalized);
         settingsService.saveGeneralSettings(updated);
@@ -239,8 +241,8 @@ public class LlmConfigService {
                 .apiKey(apiKey)
                 .apiBase(apiBase)
                 .model(model)
-                .maxTokens(DEFAULT_MAX_TOKENS)
-                .temperature(DEFAULT_TEMPERATURE)
+                .maxTokens(settings.getMaxTokens() != null ? 
settings.getMaxTokens() : DEFAULT_MAX_TOKENS)
+                .temperature(settings.getTemperature() != null ? 
settings.getTemperature() : DEFAULT_TEMPERATURE)
                 .enabled(!requiresApiKey(provider) || 
!!StringUtils.hasText(apiKey))
                 .deploymentName(defaultString(settings.getDeploymentName(), 
""))
                 .apiVersion(defaultString(settings.getApiVersion(), 
"2024-02-15-preview"))
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
index 2ea5eda4..8ec0f825 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmGateway.java
@@ -29,9 +29,12 @@ import 
org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
+import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
 
 @Slf4j
 @Primary
@@ -43,7 +46,12 @@ public class OpenAiCompatibleLlmGateway implements 
LlmGateway {
     private final OpenAiCompatibleLlmClient llmClient;
     private final AgentProviderRegistry agentProviders;
     private final ObjectMapper objectMapper;
-    private final ExecutorService executor = Executors.newCachedThreadPool();
+    // Bounded pool: cached threads grow without limit under load and, 
combined with a hung CLI
+    // child, can exhaust memory. CallerRunsPolicy keeps SSE work from being 
dropped under load.
+    private final ExecutorService executor = new ThreadPoolExecutor(
+            0, 16, 60L, TimeUnit.SECONDS,
+            new SynchronousQueue<>(),
+            new ThreadPoolExecutor.CallerRunsPolicy());
 
     @Override
     public SseEmitter chat(ChatDTO request) {
@@ -83,7 +91,8 @@ public class OpenAiCompatibleLlmGateway implements LlmGateway 
{
 
     /** Request-level engine (per-user preference) overrides the global 
config. */
     private String resolveEngine(String requestEngine, LlmConfigVO config) {
-        String engine = StringUtils.hasText(requestEngine) ? 
requestEngine.trim().toLowerCase() : null;
+        String engine = StringUtils.hasText(requestEngine)
+                ? requestEngine.trim().toLowerCase(Locale.ROOT) : null;
         if (engine == null) {
             return config.normalizeEngine();
         }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandler.java
new file mode 100644
index 00000000..6ea69ca0
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandler.java
@@ -0,0 +1,94 @@
+/*
+ * 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.ops.ai.tool;
+
+import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
+import org.apache.rocketmq.studio.instance.message.MessageService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Queries messages in a RocketMQ instance by topic, message id, business key 
or time range. Read-only
+ * and safe for AI/MCP/CLI callers; delegates to the standard message service 
used by the web UI.
+ */
+@Component
+@RequiredArgsConstructor
+public class MessageQueryToolHandler implements ToolHandler {
+
+    private static final String NAME = "rmq.message.query";
+
+    private final MessageService messageService;
+
+    @Override
+    public String name() {
+        return NAME;
+    }
+
+    @Override
+    public Object execute(Map<String, Object> input) {
+        String instanceId = (String) input.get("cluster");
+        String topic = (String) input.get("topic");
+        String msgId = (String) input.get("msgId");
+        String tag = (String) input.get("tag");
+        String key = (String) input.get("key");
+        Long startTime = asLong(input.get("startTime"));
+        Long endTime = asLong(input.get("endTime"));
+        return messageService.queryMessages(instanceId, topic, msgId, tag, 
key, startTime, endTime).stream()
+                .map(MessageQueryToolHandler::safeProjection)
+                .toList();
+    }
+
+    private static Map<String, Object> safeProjection(MessageRecordVO message) 
{
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("msgId", require(message.getMsgId(), "msgId"));
+        result.put("topic", blankIfNull(message.getTopic()));
+        result.put("tag", blankIfNull(message.getTag()));
+        result.put("key", blankIfNull(message.getKey()));
+        result.put("storeTime", message.getStoreTime());
+        result.put("storeHost", blankIfNull(message.getStoreHost()));
+        result.put("bornHost", blankIfNull(message.getBornHost()));
+        result.put("body", blankIfNull(message.getBody()));
+        result.put("bodyEncoding", blankIfNull(message.getBodyEncoding()));
+        result.put("bodyTruncated", message.isBodyTruncated());
+        result.put("size", message.getSize());
+        return result;
+    }
+
+    private static Long asLong(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Number number) {
+            return number.longValue();
+        }
+        return Long.parseLong(value.toString());
+    }
+
+    private static String require(String value, String field) {
+        if (value == null || value.isBlank()) {
+            throw new IllegalStateException("Message " + field + " is 
unavailable");
+        }
+        return value;
+    }
+
+    private static String blankIfNull(String value) {
+        return value == null ? "" : value;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/MessageTraceToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/MessageTraceToolHandler.java
new file mode 100644
index 00000000..ef30a5e4
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/MessageTraceToolHandler.java
@@ -0,0 +1,89 @@
+/*
+ * 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.ops.ai.tool;
+
+import org.apache.rocketmq.studio.instance.message.ConsumerStatusVO;
+import org.apache.rocketmq.studio.instance.message.MessageService;
+import org.apache.rocketmq.studio.instance.message.TraceNodeVO;
+import org.apache.rocketmq.studio.instance.message.TraceRecordVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Returns the message trace timeline for one message id. Read-only and safe 
for AI/MCP/CLI callers.
+ */
+@Component
+@RequiredArgsConstructor
+public class MessageTraceToolHandler implements ToolHandler {
+
+    private static final String NAME = "rmq.message.trace";
+
+    private final MessageService messageService;
+
+    @Override
+    public String name() {
+        return NAME;
+    }
+
+    @Override
+    public Object execute(Map<String, Object> input) {
+        String instanceId = (String) input.get("cluster");
+        String msgId = (String) input.get("msgId");
+        String topic = (String) input.get("topic");
+        TraceRecordVO trace = messageService.getMessageTrace(instanceId, 
msgId, topic);
+        return project(msgId, trace);
+    }
+
+    private static Map<String, Object> project(String msgId, TraceRecordVO 
trace) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("msgId", msgId);
+        result.put("nodes", trace.getNodes().stream()
+                .map(MessageTraceToolHandler::projectNode)
+                .toList());
+        result.put("consumerStatus", trace.getConsumerStatus().stream()
+                .map(MessageTraceToolHandler::projectConsumerStatus)
+                .toList());
+        return result;
+    }
+
+    private static Map<String, Object> projectNode(TraceNodeVO node) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("title", blankIfNull(node.getTitle()));
+        result.put("timestamp", node.getTimestamp());
+        result.put("status", blankIfNull(node.getStatus()));
+        result.put("costTime", node.getCostTime());
+        result.put("description", blankIfNull(node.getDescription()));
+        return result;
+    }
+
+    private static Map<String, Object> projectConsumerStatus(ConsumerStatusVO 
status) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("group", blankIfNull(status.getGroup()));
+        result.put("deliveryStatus",
+                status.getDeliveryStatus() == null ? "" : 
status.getDeliveryStatus().name());
+        result.put("consumeTime", status.getConsumeTime());
+        result.put("retryCount", status.getRetryCount());
+        return result;
+    }
+
+    private static String blankIfNull(String value) {
+        return value == null ? "" : value;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
index bdcd48d5..814205bf 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
@@ -47,6 +47,8 @@ public class GeneralSettingsVO {
     private String deploymentName;
     private String apiVersion;
     private String awsRegion;
+    private Integer maxTokens;
+    private Double temperature;
 
     @JsonProperty(value = "apiKeyConfigured", access = 
JsonProperty.Access.READ_ONLY)
     public boolean isApiKeyConfigured() {
diff --git a/server/src/main/resources/tool-catalog/rmq-tools.yaml 
b/server/src/main/resources/tool-catalog/rmq-tools.yaml
index 42885442..867a7b11 100644
--- a/server/src/main/resources/tool-catalog/rmq-tools.yaml
+++ b/server/src/main/resources/tool-catalog/rmq-tools.yaml
@@ -303,6 +303,134 @@ tools:
             type: integer
     viewHint: table
     deprecated: false
+  - name: rmq.message.query
+    cli:
+      resource: message
+      verb: query
+    description: Query messages in a RocketMQ instance by topic, message id, 
key or time range.
+    riskLevel: L1
+    permission: message:read
+    requiredCapabilities: []
+    inputSchema:
+      type: object
+      required:
+        - cluster
+      additionalProperties: false
+      properties:
+        cluster:
+          type: string
+          minLength: 1
+        topic:
+          type: string
+        msgId:
+          type: string
+        key:
+          type: string
+        tag:
+          type: string
+        startTime:
+          type: integer
+        endTime:
+          type: integer
+    outputSchema:
+      type: array
+      items:
+        type: object
+        required:
+          - msgId
+          - topic
+        additionalProperties: false
+        properties:
+          msgId:
+            type: string
+          topic:
+            type: string
+          tag:
+            type: string
+          key:
+            type: string
+          storeTime:
+            type: integer
+          storeHost:
+            type: string
+          bornHost:
+            type: string
+          body:
+            type: string
+          bodyEncoding:
+            type: string
+          bodyTruncated:
+            type: boolean
+          size:
+            type: integer
+    viewHint: table
+    deprecated: false
+  - name: rmq.message.trace
+    cli:
+      resource: message
+      verb: trace
+    description: Return the message trace timeline for one message id.
+    riskLevel: L1
+    permission: message:read
+    requiredCapabilities: []
+    inputSchema:
+      type: object
+      required:
+        - cluster
+        - msgId
+      additionalProperties: false
+      properties:
+        cluster:
+          type: string
+          minLength: 1
+        msgId:
+          type: string
+          minLength: 1
+        topic:
+          type: string
+    outputSchema:
+      type: object
+      required:
+        - msgId
+        - nodes
+      additionalProperties: false
+      properties:
+        msgId:
+          type: string
+        nodes:
+          type: array
+          items:
+            type: object
+            required:
+              - title
+            additionalProperties: false
+            properties:
+              title:
+                type: string
+              timestamp:
+                type: integer
+              status:
+                type: string
+              costTime:
+                type: integer
+              description:
+                type: string
+        consumerStatus:
+          type: array
+          items:
+            type: object
+            additionalProperties: false
+            properties:
+              group:
+                type: string
+              deliveryStatus:
+                type: string
+              consumeTime:
+                type: integer
+              retryCount:
+                type: integer
+    viewHint: text
+    deprecated: false
   - name: rmq.alert.rule.list
     cli:
       resource: alert-rule
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiEngineLocaleTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiEngineLocaleTest.java
new file mode 100644
index 00000000..6df014e9
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiEngineLocaleTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.ops.ai;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Locale;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AiEngineLocaleTest {
+
+    @Test
+    void engineIdentifiersShouldIgnoreTheJvmDefaultLocale() {
+        Locale previous = Locale.getDefault();
+        Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+        try {
+            LlmConfigVO config = LlmConfigVO.builder().engine(" CUSTOM-CLI 
").build();
+            AgentProvider provider = new StubProvider("custom-cli");
+            AgentProviderRegistry registry = new 
AgentProviderRegistry(List.of(provider));
+
+            assertThat(config.normalizeEngine()).isEqualTo("custom-cli");
+            assertThat(registry.forEngine(" CUSTOM-CLI ")).isSameAs(provider);
+        } finally {
+            Locale.setDefault(previous);
+        }
+    }
+
+    private record StubProvider(String engine) implements AgentProvider {
+        @Override
+        public boolean available() {
+            return true;
+        }
+
+        @Override
+        public String complete(LlmConfigVO config, String prompt, String 
modelOverride) {
+            return "ok";
+        }
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
index f942b2f4..77a774cd 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
@@ -185,7 +185,11 @@ class LlmConfigServiceTest {
         assertThat(saved.getApiKey()).isEqualTo("sk-deepseek");
         assertThat(saved.getModel()).isEqualTo("deepseek-chat");
         
assertThat(saved.getBaseUrl()).isEqualTo("https://api.deepseek.com/v1";);
+        assertThat(saved.getMaxTokens()).isEqualTo(8192);
+        assertThat(saved.getTemperature()).isEqualTo(0.2);
         
assertThat(llmConfigService.getConfig().getProvider()).isEqualTo("deepseek");
+        
assertThat(llmConfigService.getConfig().getMaxTokens()).isEqualTo(8192);
+        
assertThat(llmConfigService.getConfig().getTemperature()).isEqualTo(0.2);
     }
 
     @Test
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandlerTest.java
new file mode 100644
index 00000000..067ca8c8
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageQueryToolHandlerTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.ops.ai.tool;
+
+import org.apache.rocketmq.studio.instance.message.MessageRecordVO;
+import org.apache.rocketmq.studio.instance.message.MessageService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MessageQueryToolHandlerTest {
+
+    @Mock
+    private MessageService messageService;
+
+    @InjectMocks
+    private MessageQueryToolHandler handler;
+
+    @Test
+    void executeShouldDelegateToMessageServiceAndProject() {
+        MessageRecordVO message = MessageRecordVO.builder()
+                .msgId("msg-1")
+                .topic("TopicA")
+                .tag("tag1")
+                .key("key1")
+                .body("hello")
+                .bodyEncoding("UTF-8")
+                .bodyTruncated(false)
+                .storeTime(1000L)
+                .bornHost("10.0.0.1")
+                .storeHost("10.0.0.2")
+                .size(5)
+                .build();
+        when(messageService.queryMessages(eq("instance-a"), eq("TopicA"), 
any(), any(), any(), any(), any()))
+                .thenReturn(List.of(message));
+
+        Object result = handler.execute(Map.of("cluster", "instance-a", 
"topic", "TopicA"));
+
+        assertThat(result).isInstanceOf(List.class);
+        List<?> rows = (List<?>) result;
+        assertThat(rows).hasSize(1);
+        Map<?, ?> row = (Map<?, ?>) rows.get(0);
+        assertThat(row.get("msgId")).isEqualTo("msg-1");
+        assertThat(row.get("topic")).isEqualTo("TopicA");
+        assertThat(row.get("tag")).isEqualTo("tag1");
+        assertThat(row.get("storeTime")).isEqualTo(1000L);
+        assertThat(row.get("body")).isEqualTo("hello");
+        assertThat(row.get("size")).isEqualTo(5);
+    }
+
+    @Test
+    void executeShouldConvertNumericTimeArguments() {
+        when(messageService.queryMessages(any(), any(), any(), any(), any(), 
any(), any()))
+                .thenReturn(List.of());
+
+        handler.execute(Map.of("cluster", "instance-a", "topic", "TopicA",
+                "startTime", 1000L, "endTime", 2000L));
+
+        verify(messageService)
+                .queryMessages(eq("instance-a"), any(), any(), any(), any(), 
eq(1000L), eq(2000L));
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageTraceToolHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageTraceToolHandlerTest.java
new file mode 100644
index 00000000..e495a8ca
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/MessageTraceToolHandlerTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.ops.ai.tool;
+
+import org.apache.rocketmq.studio.instance.message.ConsumerStatusVO;
+import org.apache.rocketmq.studio.instance.message.MessageService;
+import org.apache.rocketmq.studio.instance.message.TraceNodeVO;
+import org.apache.rocketmq.studio.instance.message.TraceRecordVO;
+import org.apache.rocketmq.studio.common.domain.enums.DeliveryStatus;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MessageTraceToolHandlerTest {
+
+    @Mock
+    private MessageService messageService;
+
+    @InjectMocks
+    private MessageTraceToolHandler handler;
+
+    @Test
+    void executeShouldDelegateToMessageServiceAndProject() {
+        TraceRecordVO trace = TraceRecordVO.builder()
+                .nodes(List.of(TraceNodeVO.builder()
+                        .title("Send message")
+                        .timestamp(1000L)
+                        .status("SUCCESS")
+                        .costTime(5L)
+                        .description("msg-1 sent")
+                        .build()))
+                .consumerStatus(List.of(ConsumerStatusVO.builder()
+                        .group("group-a")
+                        .deliveryStatus(DeliveryStatus.success)
+                        .consumeTime(2000L)
+                        .retryCount(0)
+                        .build()))
+                .build();
+        when(messageService.getMessageTrace(eq("instance-a"), eq("msg-1"), 
eq("TopicA")))
+                .thenReturn(trace);
+
+        Object result = handler.execute(Map.of(
+                "cluster", "instance-a", "msgId", "msg-1", "topic", "TopicA"));
+
+        assertThat(result).isInstanceOf(Map.class);
+        Map<?, ?> row = (Map<?, ?>) result;
+        assertThat(row.get("msgId")).isEqualTo("msg-1");
+        List<?> nodes = (List<?>) row.get("nodes");
+        assertThat(nodes).hasSize(1);
+        Map<?, ?> node = (Map<?, ?>) nodes.get(0);
+        assertThat(node.get("title")).isEqualTo("Send message");
+        assertThat(node.get("status")).isEqualTo("SUCCESS");
+        List<?> statuses = (List<?>) row.get("consumerStatus");
+        assertThat(statuses).hasSize(1);
+        Map<?, ?> status = (Map<?, ?>) statuses.get(0);
+        assertThat(status.get("group")).isEqualTo("group-a");
+        assertThat(status.get("deliveryStatus")).isEqualTo("success");
+
+        verify(messageService).getMessageTrace("instance-a", "msg-1", 
"TopicA");
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
index 0a46bb20..1389e979 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
@@ -44,6 +44,8 @@ class ToolCatalogTest {
                         "rmq.dashboard.summary",
                         "rmq.topic.list",
                         "rmq.group.list",
+                        "rmq.message.query",
+                        "rmq.message.trace",
                         "rmq.alert.rule.list",
                         "rmq.nameserver.config.diff");
         assertThat(catalog.find("rmq.cluster.list")).isPresent();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
index e3fc309a..9876b4b7 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
@@ -29,6 +29,7 @@ import 
org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
 import org.apache.rocketmq.studio.common.domain.enums.TopicType;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
+import org.apache.rocketmq.studio.instance.message.MessageService;
 import org.apache.rocketmq.studio.instance.topic.MetadataService;
 import org.apache.rocketmq.studio.instance.topic.TopicVO;
 import org.apache.rocketmq.studio.ops.ai.AiToolVO;
@@ -60,6 +61,7 @@ class ToolGatewayServiceTest {
     private ToolCatalog catalog;
     private ClusterService clusterService;
     private DashboardService dashboardService;
+    private MessageService messageService;
     private MetadataService metadataService;
     private AlertService alertService;
     private NameServerConfigDiffService nameServerConfigDiffService;
@@ -71,6 +73,8 @@ class ToolGatewayServiceTest {
     private ConsumerGroupListToolHandler consumerGroupListHandler;
     private AlertRuleListToolHandler alertRuleListHandler;
     private NameServerConfigDiffToolHandler nameServerConfigDiffHandler;
+    private MessageQueryToolHandler messageQueryHandler;
+    private MessageTraceToolHandler messageTraceHandler;
     private ToolGatewayService gateway;
 
     @BeforeEach
@@ -78,6 +82,7 @@ class ToolGatewayServiceTest {
         catalog = canonicalCatalog();
         clusterService = mock(ClusterService.class);
         dashboardService = mock(DashboardService.class);
+        messageService = mock(MessageService.class);
         metadataService = mock(MetadataService.class);
         alertService = mock(AlertService.class);
         nameServerConfigDiffService = mock(NameServerConfigDiffService.class);
@@ -90,6 +95,8 @@ class ToolGatewayServiceTest {
         alertRuleListHandler = new AlertRuleListToolHandler(alertService);
         nameServerConfigDiffHandler = new NameServerConfigDiffToolHandler(
                 nameServerConfigDiffService);
+        messageQueryHandler = new MessageQueryToolHandler(messageService);
+        messageTraceHandler = new MessageTraceToolHandler(messageService);
         gateway = gateway(
                 catalog,
                 clusterListHandler,
@@ -98,7 +105,9 @@ class ToolGatewayServiceTest {
                 topicListHandler,
                 consumerGroupListHandler,
                 alertRuleListHandler,
-                nameServerConfigDiffHandler);
+                nameServerConfigDiffHandler,
+                messageQueryHandler,
+                messageTraceHandler);
     }
 
     @Test
@@ -121,6 +130,8 @@ class ToolGatewayServiceTest {
                         "rmq.dashboard.summary",
                         "rmq.topic.list",
                         "rmq.group.list",
+                        "rmq.message.query",
+                        "rmq.message.trace",
                         "rmq.alert.rule.list",
                         "rmq.nameserver.config.diff");
     }
@@ -481,7 +492,9 @@ class ToolGatewayServiceTest {
                 topicListHandler,
                 consumerGroupListHandler,
                 alertRuleListHandler,
-                nameServerConfigDiffHandler);
+                nameServerConfigDiffHandler,
+                messageQueryHandler,
+                messageTraceHandler);
 
         assertThatThrownBy(() -> l2Gateway.execute("rmq.cluster.list", 
Map.of()))
                 .isInstanceOf(BusinessException.class)
@@ -533,7 +546,9 @@ class ToolGatewayServiceTest {
                 topicListHandler,
                 consumerGroupListHandler,
                 alertRuleListHandler,
-                nameServerConfigDiffHandler))
+                nameServerConfigDiffHandler,
+                messageQueryHandler,
+                messageTraceHandler))
                 .isInstanceOf(IllegalStateException.class)
                 .hasMessageContaining("input schema")
                 .hasMessageContaining("rmq.cluster.list");
@@ -559,7 +574,9 @@ class ToolGatewayServiceTest {
                 topicListHandler,
                 consumerGroupListHandler,
                 alertRuleListHandler,
-                nameServerConfigDiffHandler))
+                nameServerConfigDiffHandler,
+                messageQueryHandler,
+                messageTraceHandler))
                 .isInstanceOf(IllegalStateException.class)
                 .hasMessageContaining("input schema")
                 .hasMessageContaining("rmq.cluster.list");
@@ -586,7 +603,9 @@ class ToolGatewayServiceTest {
                 topicListHandler,
                 consumerGroupListHandler,
                 alertRuleListHandler,
-                nameServerConfigDiffHandler);
+                nameServerConfigDiffHandler,
+                messageQueryHandler,
+                messageTraceHandler);
 
         assertThatThrownBy(() -> invalidGateway.execute("rmq.cluster.list", 
Map.of()))
                 .isInstanceOf(IllegalStateException.class)

Reply via email to