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 1053cd4be fix(ai): harden LLM streaming, endpoint resolution and agent 
runtime (#6998)
1053cd4be is described below

commit 1053cd4be782210833c35c914d136de7a29c95c6
Author: Wang1rrr <[email protected]>
AuthorDate: Tue Oct 27 12:04:27 2026 +0800

    fix(ai): harden LLM streaming, endpoint resolution and agent runtime (#6998)
    
    * fix(ai): preserve query parameters when resolving API endpoints (#6998)
    Both apiBase normalizations rebuilt the endpoint by string concatenation, 
so a
    configured base carrying a query string or a fragment produced a malformed 
URL.
    LlmApiBase now resolves the base through URI components and inserts the path
    ahead of the query and fragment; LlmConfigService and 
OpenAiCompatibleLlmClient
    share it instead of keeping two private copies.
    * fix(ai): share the stream timeout budget with header reads (#6983)
    The SSE reader task was granted a fresh requestTimeout even though waiting 
for
    the response headers had already consumed part of that same budget, so a 
slow
    provider could hold one call for nearly twice the configured timeout. The
    reader now gets the remaining budget.
    * fix(ai): preserve the first event in BOM-prefixed SSE streams (#6994)
    SSE permits a single UTF-8 BOM at the start of the stream, before any field
    name. Strip one leading U+FEFF from the first line only and leave interior
    BOMs alone, so the first event is no longer misparsed.
    * fix(ai): cancel rejected stream response bodies (#6967)
    assertEventStream rethrew without releasing the response body, leaking the
    connection on every rejected stream. Cancel the body first, then rethrow the
    original error.
    * fix(ai): bound partial SSE frames after draining complete events (#6976)
    The frame size limit was applied right after the read and before complete
    events were drained, so a large but well-formed chunk was reported as an
    oversized remainder. Only the unfinished trailing frame is bounded now.
    * fix(ai): serialize capability probe cache refreshes (#6971)
    Concurrent callers each started their own agent capability probe and all of
    them waited the full 15s. binaries() now refreshes under a lock and reuses 
the
    result of the probe already in flight.
    * fix(ai): resolve workspace paths before changing child cwd (#6980)
    A relative configured path was resolved against the child process working
    directory, which the caller had already changed. workspaceRoot() now makes 
the
    path absolute and normalized before it is handed down.
    * fix(ai): preserve Unicode characters in terminal run errors (#7002)
    Truncating the terminal error fields by char could split a surrogate pair 
and
    leave the run unpersistable. Use TextBounds.truncate, which respects code 
point
    boundaries.
    
    Batch integration of eight related fixes by the same contributor; #6967,
    #6971, #6976, #6980, #6983, #6994 and #7002 are closed as merged into this 
one.
---
 .../apache/rocketmq/studio/ops/ai/LlmApiBase.java  | 57 +++++++++++++
 .../rocketmq/studio/ops/ai/LlmConfigService.java   | 14 +---
 .../studio/ops/ai/OpenAiCompatibleLlmClient.java   | 37 ++++-----
 .../studio/ops/ai/conversation/AiRunExecutor.java  |  4 +-
 .../conversation/agent/AgentCapabilityProbe.java   |  4 +-
 .../ops/ai/conversation/agent/RmqctlWorkspace.java |  3 +-
 .../studio/ops/ai/LlmConfigServiceTest.java        | 20 +++++
 .../ops/ai/OpenAiCompatibleLlmClientTest.java      | 77 +++++++++++++++++
 .../ops/ai/conversation/AiRunExecutorTest.java     | 31 +++++++
 .../agent/AgentCapabilityProbeConcurrencyTest.java | 96 ++++++++++++++++++++++
 .../ai/conversation/agent/RmqctlWorkspaceTest.java | 20 +++++
 web/src/api/ai.test.ts                             | 76 +++++++++++++++++
 web/src/api/ai.ts                                  | 14 +++-
 13 files changed, 412 insertions(+), 41 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmApiBase.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmApiBase.java
new file mode 100644
index 000000000..12e5568e1
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmApiBase.java
@@ -0,0 +1,57 @@
+/*
+ * 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 java.net.URI;
+
+/** Normalizes endpoint paths without changing raw query parameters or 
fragments. */
+final class LlmApiBase {
+    private static final String CHAT_COMPLETIONS_PATH = "/chat/completions";
+
+    private LlmApiBase() {
+    }
+
+    static String normalize(String apiBase) {
+        String trimmed = apiBase.trim();
+        int suffixStart = suffixStart(trimmed);
+        String base = trimTrailingSlashes(trimmed.substring(0, suffixStart));
+        if (base.endsWith(CHAT_COMPLETIONS_PATH)) {
+            base = trimTrailingSlashes(base.substring(0, base.length() - 
CHAT_COMPLETIONS_PATH.length()));
+        }
+        return base + trimmed.substring(suffixStart);
+    }
+
+    static URI endpoint(String apiBase, String path) {
+        String normalized = normalize(apiBase);
+        int suffixStart = suffixStart(normalized);
+        return URI.create(normalized.substring(0, suffixStart) + path + 
normalized.substring(suffixStart));
+    }
+
+    private static int suffixStart(String value) {
+        int query = value.indexOf('?');
+        int fragment = value.indexOf('#');
+        return Math.min(query < 0 ? value.length() : query, fragment < 0 ? 
value.length() : fragment);
+    }
+
+    private static String trimTrailingSlashes(String value) {
+        int end = value.length();
+        while (end > 0 && value.charAt(end - 1) == '/') {
+            end--;
+        }
+        return value.substring(0, end);
+    }
+}
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 feabb0dcd..7e1ecd4cd 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
@@ -359,17 +359,7 @@ public class LlmConfigService {
     }
 
     private String normalizeApiBase(String apiBase) {
-        String normalized = apiBase.trim();
-        while (normalized.endsWith("/")) {
-            normalized = normalized.substring(0, normalized.length() - 1);
-        }
-        if (normalized.endsWith(CHAT_COMPLETIONS_PATH)) {
-            normalized = normalized.substring(0, normalized.length() - 
CHAT_COMPLETIONS_PATH.length());
-            while (normalized.endsWith("/")) {
-                normalized = normalized.substring(0, normalized.length() - 1);
-            }
-        }
-        return normalized;
+        return LlmApiBase.normalize(apiBase);
     }
 
     private boolean isValidApiBase(String apiBase) {
@@ -377,7 +367,7 @@ public class LlmConfigService {
             URI uri = new URI(apiBase);
             String scheme = uri.getScheme() == null ? "" : 
uri.getScheme().toLowerCase(Locale.ROOT);
             if (!("http".equals(scheme) || "https".equals(scheme)) || 
!StringUtils.hasText(uri.getHost())
-                    || apiBase.endsWith(CHAT_COMPLETIONS_PATH)) {
+                    || uri.getRawPath().endsWith(CHAT_COMPLETIONS_PATH)) {
                 return false;
             }
             // SSRF guard on both the save and test paths (validate() runs for 
each). Loopback is
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
index 42173aa6a..762a33fe6 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
@@ -180,12 +180,13 @@ public class OpenAiCompatibleLlmClient {
         messages.add(Map.of("role", "user", "content", 
StringUtils.hasText(prompt) ? prompt.trim() : ""));
         requestBody.put("messages", messages);
         HttpRequest request = request(config, "text/event-stream", 
requestBody);
+        long startedAtNanos = System.nanoTime();
         try {
             HttpResponse<StreamBody> response = httpClient.send(request, 
streamBodyHandler());
             if (response.statusCode() >= 400) {
                 throw upstreamException(response.statusCode(), 
checkedBody(response.body().errorBody()));
             }
-            parseStreamWithTimeout(response.body().stream(), tokenConsumer);
+            parseStreamWithTimeout(response.body().stream(), tokenConsumer, 
startedAtNanos);
         } catch (ResponseLimitException exception) {
             throw new LlmGatewayException(502, 
"llm.provider.response_too_large",
                     "LLM provider stream exceeded the maximum of " + 
exception.limitBytes() + " bytes",
@@ -205,7 +206,7 @@ public class OpenAiCompatibleLlmClient {
         }
     }
 
-    private void parseStreamWithTimeout(InputStream input, Consumer<String> 
tokenConsumer)
+    private void parseStreamWithTimeout(InputStream input, Consumer<String> 
tokenConsumer, long startedAtNanos)
             throws IOException, InterruptedException {
         FutureTask<Void> readerTask = new FutureTask<>(() -> {
             parseStream(input, tokenConsumer);
@@ -213,7 +214,10 @@ public class OpenAiCompatibleLlmClient {
         });
         Thread.ofVirtual().name("openai-sse-reader").start(readerTask);
         try {
-            readerTask.get(requestTimeout.toNanos(), TimeUnit.NANOSECONDS);
+            // The input-stream body handler returns after headers; their wait 
has already
+            // consumed part of the same request budget.
+            long remainingNanos = requestTimeout.toNanos() - 
(System.nanoTime() - startedAtNanos);
+            readerTask.get(remainingNanos, TimeUnit.NANOSECONDS);
         } catch (TimeoutException exception) {
             throw new HttpTimeoutException("LLM provider stream timed out");
         } catch (ExecutionException exception) {
@@ -249,8 +253,16 @@ public class OpenAiCompatibleLlmClient {
         try (BufferedReader reader = new BufferedReader(
                 new InputStreamReader(input, StandardCharsets.UTF_8))) {
             List<String> dataLines = new ArrayList<>();
+            boolean firstLine = true;
             String line;
             while ((line = reader.readLine()) != null) {
+                if (firstLine) {
+                    // SSE permits one UTF-8 BOM at the start of the stream, 
before any field name.
+                    if (line.startsWith("\uFEFF")) {
+                        line = line.substring(1);
+                    }
+                    firstLine = false;
+                }
                 if (line.isEmpty()) {
                     if (emitStreamEvent(dataLines, tokenConsumer)) {
                         return;
@@ -363,22 +375,7 @@ public class OpenAiCompatibleLlmClient {
     }
 
     private URI providerUri(LlmConfigVO config, String path) {
-        String baseUrl = normalizeApiBase(config.getApiBase());
-        return URI.create(baseUrl + path);
-    }
-
-    private String normalizeApiBase(String apiBase) {
-        String baseUrl = apiBase.trim();
-        while (baseUrl.endsWith("/")) {
-            baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
-        }
-        if (baseUrl.endsWith(CHAT_COMPLETIONS_PATH)) {
-            baseUrl = baseUrl.substring(0, baseUrl.length() - 
CHAT_COMPLETIONS_PATH.length());
-            while (baseUrl.endsWith("/")) {
-                baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
-            }
-        }
-        return baseUrl;
+        return LlmApiBase.endpoint(config.getApiBase(), path);
     }
 
     private void validate(LlmConfigVO config) {
@@ -415,7 +412,7 @@ public class OpenAiCompatibleLlmClient {
 
     private boolean isValidApiBase(String apiBase) {
         try {
-            URI uri = new URI(normalizeApiBase(apiBase));
+            URI uri = new URI(LlmApiBase.normalize(apiBase));
             String scheme = uri.getScheme() == null ? "" : 
uri.getScheme().toLowerCase();
             return ("http".equals(scheme) || "https".equals(scheme)) && 
StringUtils.hasText(uri.getHost());
         } catch (IllegalArgumentException | URISyntaxException exception) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutor.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutor.java
index c99549af6..369cb1e47 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutor.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutor.java
@@ -21,6 +21,7 @@ import jakarta.annotation.PreDestroy;
 import lombok.Getter;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.common.util.TextBounds;
 import org.apache.rocketmq.studio.ops.ai.AgentProviderRegistry;
 import org.apache.rocketmq.studio.ops.ai.LlmConfigVO;
 import org.apache.rocketmq.studio.ops.ai.LlmGatewayException;
@@ -656,8 +657,7 @@ public class AiRunExecutor {
         if (!StringUtils.hasText(value)) {
             return null;
         }
-        String trimmed = value.trim();
-        return trimmed.length() <= maxChars ? trimmed : trimmed.substring(0, 
maxChars);
+        return TextBounds.truncate(value.trim(), maxChars);
     }
 
     static boolean isHttpEngine(String engine) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/AgentCapabilityProbe.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/AgentCapabilityProbe.java
index b473fe232..4f43e7946 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/AgentCapabilityProbe.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/AgentCapabilityProbe.java
@@ -91,7 +91,7 @@ public class AgentCapabilityProbe {
     }
 
     /** The three CLI probes, cached together so one request costs at most one 
round of forks. */
-    public Binaries binaries() {
+    public synchronized Binaries binaries() {
         Instant now = clock.instant();
         Binaries current = cached.get();
         if (current != null && Duration.between(current.probedAt(), 
now).compareTo(CACHE_TTL) < 0) {
@@ -102,7 +102,7 @@ public class AgentCapabilityProbe {
                 binaryProbe.isAvailable(CLAUDE_BINARY),
                 binaryProbe.isAvailable(QODER_BINARY),
                 now);
-        // Last writer wins and every writer wrote a fresh probe, so a racing 
refresh is harmless.
+        // Concurrent requests reuse this round instead of launching duplicate 
subprocesses.
         cached.set(probed);
         log.debug("probed agent binaries: rmqctl={} claude={} qoder={} (cached 
for {}s)",
                 probed.rmqctl(), probed.claude(), probed.qoder(), 
CACHE_TTL_SECONDS);
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/RmqctlWorkspace.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/RmqctlWorkspace.java
index 26174b080..d9d5cf70e 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/RmqctlWorkspace.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/RmqctlWorkspace.java
@@ -305,7 +305,8 @@ public class RmqctlWorkspace {
             throw new BusinessException(500,
                     "studio.ai.conversation.workspace-dir must not be blank");
         }
-        return Path.of(configured.trim());
+        // The agent changes cwd to its conversation directory before reading 
these paths.
+        return Path.of(configured.trim()).toAbsolutePath().normalize();
     }
 
     /** The directory one conversation runs in, also its child process working 
directory. */
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 3b5eb3a9c..17f1b8d78 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
@@ -21,6 +21,8 @@ import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
 import org.apache.rocketmq.studio.settings.SettingsService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.ArgumentCaptor;
 
 import java.util.List;
@@ -360,6 +362,24 @@ class LlmConfigServiceTest {
         
assertThat(captor.getValue().getBaseUrl()).isEqualTo("https://api.openai.com/v1";);
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {"?route=/", "?route=/chat/completions", 
"?tenant=a%2Fb+%26#gateway"})
+    void saveAndReadConfigShouldNormalizeOnlyTheUrlPathTest(String suffix) {
+        llmConfigService.saveConfig(LlmConfigVO.builder()
+                .provider("ollama")
+                .apiBase(" http://localhost:11434/v1/chat/completions/"; + 
suffix + " ")
+                .model("llama3")
+                .enabled(true)
+                .build());
+
+        ArgumentCaptor<GeneralSettingsVO> captor = 
ArgumentCaptor.forClass(GeneralSettingsVO.class);
+        verify(settingsService).saveGeneralSettings(captor.capture());
+        String expected = "http://localhost:11434/v1"; + suffix;
+        assertThat(captor.getValue().getBaseUrl()).isEqualTo(expected);
+        
when(settingsService.getGeneralSettings()).thenReturn(captor.getValue());
+        
assertThat(llmConfigService.getConfig().getApiBase()).isEqualTo(expected);
+    }
+
     @Test
     void saveConfigShouldPreserveNotificationChannelFields() {
         
when(settingsService.getGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
index 1348f480a..cd2e2401e 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.IOException;
@@ -118,6 +119,36 @@ class OpenAiCompatibleLlmClientTest {
         assertThat(requestPath.get()).isEqualTo("/v1/chat/completions");
     }
 
+    @ParameterizedTest
+    @CsvSource({"'',''", "'/chat/completions/',''", "'','#gateway'", 
"'/chat/completions/','#gateway'"})
+    void providerRequestsShouldKeepQueryParametersAfterEndpointPathTest(String 
endpoint, String fragment) {
+        List<String> requestUris = new ArrayList<>();
+        server.createContext("/", exchange -> {
+            requestUris.add(exchange.getRequestURI().toString());
+            if ("GET".equals(exchange.getRequestMethod())) {
+                respond(exchange, 200, "{\"data\":[{\"id\":\"gpt-test\"}]}", 
"application/json");
+            } else if 
(objectMapper.readTree(exchange.getRequestBody()).path("stream").asBoolean()) {
+                respond(exchange, 200,
+                        "data: 
{\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\ndata: [DONE]\n\n",
+                        "text/event-stream");
+            } else {
+                respond(exchange, 200, 
"{\"choices\":[{\"message\":{\"content\":\"hello\"}}]}", "application/json");
+            }
+        });
+        String query = "?tenant=a%2Fb+%26&route=/chat/completions/";
+        LlmConfigVO config = config("openai", "sk-test");
+        config.setApiBase(baseUrl + endpoint + query + fragment);
+
+        client.listModels(config);
+        client.complete(config, "hello", null);
+        List<String> tokens = new ArrayList<>();
+        client.stream(config, "hello", null, tokens::add);
+
+        assertThat(requestUris).containsExactly("/v1/models" + query,
+                "/v1/chat/completions" + query, "/v1/chat/completions" + 
query);
+        assertThat(tokens).containsExactly("hello");
+    }
+
     @Test
     void completeShouldRejectInvalidApiBaseBeforeCallingUpstream() {
         LlmConfigVO config = config("openai", "sk-test");
@@ -166,6 +197,19 @@ class OpenAiCompatibleLlmClientTest {
                 .hasMessageContaining("UTF-8 bytes");
     }
 
+    @Test
+    void streamShouldIgnoreOnlyTheLeadingUtf8BomTest() {
+        server.createContext("/v1/chat/completions", exchange -> 
respond(exchange, 200,
+                "\uFEFFdata: 
{\"choices\":[{\"delta\":{\"content\":\"first\"}}]}\n\n"
+                        + "data: 
{\"choices\":[{\"delta\":{\"content\":\"inner\uFEFFtext\"}}]}\n\n"
+                        + "data: [DONE]\n\n", "text/event-stream"));
+        List<String> tokens = new ArrayList<>();
+
+        client.stream(config("openai", "sk-test"), "hello", null, tokens::add);
+
+        assertThat(tokens).containsExactly("first", "inner\uFEFFtext");
+    }
+
     @Test
     void streamShouldParseOpenAiCompatibleSseDeltas() {
         AtomicReference<JsonNode> requestBody = new AtomicReference<>();
@@ -296,6 +340,39 @@ class OpenAiCompatibleLlmClientTest {
         assertThat(tokens).containsExactly("first");
     }
 
+    @Test
+    void streamTimeoutIncludesTimeSpentWaitingForHeadersTest() {
+        OpenAiCompatibleLlmClient timeoutClient = new 
OpenAiCompatibleLlmClient(
+                objectMapper, HttpClient.newHttpClient(), 
Duration.ofSeconds(1));
+        server.createContext("/v1/chat/completions", exchange -> {
+            exchange.getRequestBody().readAllBytes();
+            try {
+                Thread.sleep(600);
+                exchange.getResponseHeaders().set("Content-Type", 
"text/event-stream");
+                exchange.sendResponseHeaders(200, 0);
+                exchange.getResponseBody().flush();
+                Thread.sleep(600);
+                exchange.getResponseBody().write("""
+                        data: {"choices":[{"delta":{"content":"late answer"}}]}
+
+                        data: [DONE]
+
+                        """.getBytes(StandardCharsets.UTF_8));
+            } catch (InterruptedException exception) {
+                Thread.currentThread().interrupt();
+            } finally {
+                exchange.close();
+            }
+        });
+
+        assertThatThrownBy(() -> timeoutClient.stream(
+                config("openai", "sk-test"), "hello", null, token -> { }))
+                .isInstanceOfSatisfying(LlmGatewayException.class, exception 
-> {
+                    assertThat(exception.getStatusCode()).isEqualTo(504);
+                    
assertThat(exception.getCode()).isEqualTo("llm.provider.timeout");
+                });
+    }
+
     @Test
     void ollamaShouldAllowMissingApiKeyAndOmitAuthorizationHeader() {
         AtomicReference<String> authorization = new AtomicReference<>();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutorTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutorTest.java
index f3b5fa477..ced1a222f 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutorTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunExecutorTest.java
@@ -35,11 +35,14 @@ import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 
+import java.nio.charset.StandardCharsets;
 import java.time.Clock;
 import java.time.Duration;
 import java.time.Instant;
@@ -492,6 +495,34 @@ class AiRunExecutorTest {
         assertThat(emitters.get(0).completed()).isTrue();
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {"\uD83D\uDE00", "\uD840\uDC00"})
+    void providerFailureShouldTruncateAtCodePointBoundariesTest(String 
supplementaryCharacter) {
+        String codePrefix = "c".repeat(63) + supplementaryCharacter;
+        String messagePrefix = "m".repeat(999) + supplementaryCharacter;
+        provider.failure = new LlmGatewayException(502, codePrefix + 
"overflow",
+                messagePrefix + "overflow", "Retry.");
+
+        startAndRun();
+
+        assertThat(runRow().getStatus()).isEqualTo(RunStatus.FAILED.name());
+        
assertThat(StandardCharsets.UTF_8.newEncoder().canEncode(runRow().getErrorMessage())).isTrue();
+        
assertThat(StandardCharsets.UTF_8.newEncoder().canEncode(runRow().getErrorCode())).isTrue();
+        assertThat(runRow().getErrorCode()).isEqualTo(codePrefix);
+        assertThat(runRow().getErrorMessage()).isEqualTo(messagePrefix);
+        assertThat(emitters.get(0).completed()).isTrue();
+    }
+
+    @Test
+    void 
providerFailureShouldKeepSupplementaryCharactersWithinTheColumnBudgetTest() {
+        String message = "\uD83D\uDE00".repeat(750);
+        provider.failure = new LlmGatewayException(502, "llm.provider.error", 
" " + message + " ", "Retry.");
+
+        startAndRun();
+
+        assertThat(runRow().getErrorMessage()).isEqualTo(message);
+    }
+
     @Test
     void anErrorFromTheWorkerShouldNotBeRecordedAsACompletedRunTest() {
         provider.emit(new AgentEvent.TextDelta("half an answer"));
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/AgentCapabilityProbeConcurrencyTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/AgentCapabilityProbeConcurrencyTest.java
new file mode 100644
index 000000000..61c2b6c9e
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/AgentCapabilityProbeConcurrencyTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.conversation.agent;
+
+import org.apache.rocketmq.studio.ops.ai.conversation.AiConversationProperties;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class AgentCapabilityProbeConcurrencyTest {
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void concurrentRequestsShareColdOrExpiredProbeRoundTest(boolean expired) 
throws Exception {
+        CliBinaryProbe binaryProbe = mock(CliBinaryProbe.class);
+        Clock clock = mock(Clock.class);
+        Instant start = Instant.parse("2026-10-01T00:00:00Z");
+        when(clock.instant()).thenReturn(start);
+        when(binaryProbe.isAvailable(anyString())).thenReturn(true);
+        AiConversationProperties properties = new AiConversationProperties();
+        properties.setRmqctlEnabled(true);
+        AgentCapabilityProbe probe = new AgentCapabilityProbe(binaryProbe, 
properties, true, false, clock);
+        if (expired) {
+            probe.binaries();
+            when(clock.instant()).thenReturn(start.plusSeconds(60));
+        }
+
+        CountDownLatch entered = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        AtomicInteger calls = new AtomicInteger();
+        when(binaryProbe.isAvailable(anyString())).thenAnswer(invocation -> {
+            calls.incrementAndGet();
+            entered.countDown();
+            assertThat(release.await(5, TimeUnit.SECONDS)).isTrue();
+            return true;
+        });
+        FutureTask<AgentCapabilityProbe.Binaries> first = new 
FutureTask<>(probe::binaries);
+        FutureTask<AgentCapabilityProbe.Binaries> second = new 
FutureTask<>(probe::binaries);
+        Thread firstThread = new Thread(first, "first-capability-request");
+        Thread secondThread = new Thread(second, "second-capability-request");
+        firstThread.start();
+        try {
+            assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue();
+            secondThread.start();
+            awaitProbeOrMonitor(secondThread);
+        } finally {
+            release.countDown();
+            firstThread.join(TimeUnit.SECONDS.toMillis(5));
+            secondThread.join(TimeUnit.SECONDS.toMillis(5));
+        }
+
+        assertThat(second.get(5, TimeUnit.SECONDS)).isSameAs(first.get(5, 
TimeUnit.SECONDS));
+        assertThat(calls).hasValue(3);
+        assertThat(probe.binaries()).isSameAs(first.get());
+        assertThat(calls).hasValue(3);
+    }
+
+    private static void awaitProbeOrMonitor(Thread thread) {
+        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+        while (System.nanoTime() < deadline) {
+            Thread.State state = thread.getState();
+            StackTraceElement[] stack = thread.getStackTrace();
+            if (state == Thread.State.TIMED_WAITING || state == 
Thread.State.BLOCKED
+                    && stack.length > 0 && 
stack[0].getClassName().equals(AgentCapabilityProbe.class.getName())) {
+                return;
+            }
+            Thread.yield();
+        }
+        throw new AssertionError("second request did not reach the probe or 
refresh monitor");
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/RmqctlWorkspaceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/RmqctlWorkspaceTest.java
index 24f4f2475..a59cb7b2c 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/RmqctlWorkspaceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/RmqctlWorkspaceTest.java
@@ -118,6 +118,26 @@ class RmqctlWorkspaceTest {
                 () -> new AssertionError("the workspace should have been 
prepared"));
     }
 
+    @Test
+    void relativeWorkspacePathsRemainUsableFromChildWorkingDirectoryTest() 
throws Exception {
+        Path configuredRoot = root.resolve("agent workspaces");
+        
properties.setWorkspaceDir(Path.of("").toAbsolutePath().relativize(configuredRoot).toString());
+
+        RmqctlWorkspace.Preparation preparation = prepared();
+        Path childDirectory = 
Path.of(preparation.workspaceDir()).toAbsolutePath();
+
+        
assertThat(childDirectory.resolve(preparation.mcpConfigPath())).isRegularFile();
+        
assertThat(childDirectory.resolve(preparation.systemPromptPath())).isRegularFile();
+        
assertThat(Path.of(preparation.childEnv().get(RmqctlWorkspace.HOME_ENV)))
+                .isAbsolute().isDirectory();
+        JsonNode args = 
MAPPER.readTree(Path.of(preparation.mcpConfigPath()).toFile())
+                
.path("mcpServers").path(AgentEventProjector.STUDIO_MCP_SERVER).path("args");
+        
assertThat(childDirectory.resolve(args.get(3).asText())).isRegularFile();
+        
assertThat(availableWorkspace().workspaceRoot()).isEqualTo(configuredRoot);
+        assertThat(availableWorkspace().delete(CONVERSATION_ID)).isTrue();
+        assertThat(childDirectory).doesNotExist();
+    }
+
     @Test
     void writesRmqctlConfigWithEnvironmentReferencesOnlyTest() throws 
Exception {
         RmqctlWorkspace.Preparation preparation = prepared();
diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts
index 2de42feee..b94daa7e9 100644
--- a/web/src/api/ai.test.ts
+++ b/web/src/api/ai.test.ts
@@ -397,6 +397,48 @@ describe('AI API', () => {
       expect(onCancel).toHaveBeenCalledTimes(1);
     });
 
+    it.each([
+      ['heartbeat', ':hb\n\n'],
+      ['agent', agentFrame({ type: 'text_delta', content: 'hi' })],
+    ])('rejectsOversizedRemainderAfter%sBeforeReadingAgainTest', async (_name, 
prefix) => {
+      vi.useFakeTimers();
+      const onCancel = vi.fn();
+      const response = eventStreamResponse(
+        [`${prefix}event: agent\ndata: ${'x'.repeat(1024 * 1024)}`],
+        onCancel,
+      );
+      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
+      const failure = vi.fn();
+      const stream = openRunStream(7, body, { onEvent: vi.fn() 
}).catch(failure);
+
+      await vi.advanceTimersByTimeAsync(0);
+      const failedImmediately = failure.mock.calls.length === 1;
+      // Also settle the unfixed reader so a failed assertion cannot leave a 
pending stream.
+      await vi.advanceTimersByTimeAsync(31_000);
+      await stream;
+
+      expect(failedImmediately).toBe(true);
+      expect(failure).toHaveBeenCalledWith(
+        expect.objectContaining({ code: 'llm.stream.event_too_large' }),
+      );
+      expect(onCancel).toHaveBeenCalledOnce();
+    });
+
+    it('acceptsLargeChunksContainingIndividuallyBoundedFramesTest', async () 
=> {
+      const content = 'x'.repeat(600 * 1024);
+      const frame = agentFrame({ type: 'text_delta', content });
+      vi.stubGlobal(
+        'fetch',
+        vi.fn().mockResolvedValue(eventStreamResponse([frame + frame + 
doneFrame()])),
+      );
+      const onEvent = vi.fn();
+
+      await openRunStream(7, body, { onEvent });
+
+      expect(onEvent).toHaveBeenCalledTimes(2);
+      expect(onEvent).toHaveBeenLastCalledWith({ type: 'text_delta', content 
});
+    });
+
     it('throwsStructuredErrorsFromErrorFramesTest', async () => {
       vi.stubGlobal(
         'fetch',
@@ -472,6 +514,40 @@ describe('AI API', () => {
       );
     });
 
+    it.each(['open', 'attach'] as const)(
+      'cancelsRejectedContentTypeResponseFor%sTest',
+      async (entryPoint) => {
+        const onCancel = vi.fn();
+        const response = new Response(new ReadableStream({ cancel: onCancel 
}), {
+          headers: { 'content-type': 'text/html' },
+        });
+        vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
+
+        const stream =
+          entryPoint === 'open'
+            ? openRunStream(7, body, { onEvent: vi.fn() })
+            : attachRunStream(41, 0, { onEvent: vi.fn() });
+
+        await expect(stream).rejects.toMatchObject({
+          code: 'llm.stream.unexpected_content_type',
+        });
+        expect(onCancel).toHaveBeenCalledOnce();
+      },
+    );
+
+    it('preservesContentTypeErrorWhenCancellingTheBodyFailsTest', async () => {
+      const onCancel = vi.fn().mockRejectedValue(new Error('cancel failed'));
+      const response = new Response(new ReadableStream({ cancel: onCancel }), {
+        headers: { 'content-type': 'text/html' },
+      });
+      vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
+
+      await expect(attachRunStream(41, 0, { onEvent: vi.fn() 
})).rejects.toMatchObject({
+        code: 'llm.stream.unexpected_content_type',
+      });
+      expect(onCancel).toHaveBeenCalledOnce();
+    });
+
     it('rejectsAResponseWithoutAnyContentTypeTest', async () => {
       // A null body is the only way to get a Response with no content-type at 
all: a string body
       // makes the Fetch API default the header to text/plain.
diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts
index dd8df34f4..15cd472be 100644
--- a/web/src/api/ai.ts
+++ b/web/src/api/ai.ts
@@ -270,9 +270,6 @@ async function consumeEventStream(
       if (done) break;
 
       buffer += decoder.decode(value, { stream: true });
-      if (buffer.length > MAX_SSE_EVENT_CHARS && !getEventBoundary(buffer)) {
-        throw eventTooLargeError();
-      }
       let boundary = getEventBoundary(buffer);
       while (boundary) {
         const frame = buffer.slice(0, boundary.index);
@@ -280,6 +277,9 @@ async function consumeEventStream(
         if (dispatch(frame)) return;
         boundary = getEventBoundary(buffer);
       }
+      if (buffer.length > MAX_SSE_EVENT_CHARS) {
+        throw eventTooLargeError();
+      }
     }
 
     // A server that closes without a trailing blank line still delivered its 
last frame.
@@ -358,7 +358,13 @@ async function consumeRunStream(
   if (!response.ok) {
     throw await parseHttpError(response, label);
   }
-  assertEventStream(response);
+  try {
+    assertEventStream(response);
+  } catch (error) {
+    // No reader owns the body yet when the content-type check rejects it.
+    await response.body?.cancel().catch(() => undefined);
+    throw error;
+  }
   await consumeEventStream(
     response,
     (frame) => emitRunFrame(frame, handlers),

Reply via email to