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 085e1fedf fix(ai): stream the degraded notice only for agent-engine 
turns (#5818)
085e1fedf is described below

commit 085e1fedfcf60ccbf285bf12e7143c307d0b228c
Author: 0 <[email protected]>
AuthorDate: Mon Oct 12 16:14:50 2026 +0800

    fix(ai): stream the degraded notice only for agent-engine turns (#5818)
    
    Two fixes to `AiRunService.sendMessage`, which decides both what a degraded 
conversation tells the person watching it and whether an agent workspace is 
prepared at all; folded, the two conditions collapse into one `degraded = 
(preparation == null && !httpEngine) ? degradedNotice(conversation) : null`, so 
an `http` turn neither persists nor streams a notice it cannot act on.
    
    - #5818 `AiRunService.java:236-238` pushed the degraded/unbound notice 
through `sink.writeTimeline(...)` only, while live frames reach an attached 
observer exclusively through `registry.publish(...)`, so a client watching the 
run over SSE saw nothing until it reloaded the conversation → the notice is 
held in a local `TimelineEvent.Notice`, persisted exactly as before, and 
re-published after the `RunStarted` frame at `:243` as 
`registry.publish(run.getId(), 0L, new LiveEvent.Notice(deg [...]
    - #5819 `AiRunService.java:209` prepared an agent workspace for every 
engine, so a pure `http` completion turn — the engine 
`LlmConfigService.java:346-352` also falls back to for any unknown value — was 
failed with a 500 by a `RuntimeException` out of `prepare()` (`:210-220`) or 
collected a "RocketMQ tools unavailable" notice on every turn → 
`workspace.prepare(...)` is skipped when `AiRunExecutor.isHttpEngine(engine)` 
(a package-private static at `AiRunExecutor.java:630-633`, reachabl [...]
    
    Tests: `AiRunServiceTest` gains two cases — one counts `"type":"notice"` 
frames on a live emitter and pins the count at exactly 1, so removing the 
`registry.publish` call or duplicating it both fail, and one drives an `http` 
turn whose `workspace.prepare` would throw, asserting `verify(workspace, 
never()).prepare(...)`, a timeline with no notice row, and a run that does not 
end FAILED.
    
    Folded: #5819 was closed as folded into this commit (same author).
---
 .../studio/ops/ai/conversation/AiRunService.java   | 24 ++++++++++---
 .../ops/ai/conversation/AiRunServiceTest.java      | 39 ++++++++++++++++++++++
 2 files changed, 59 insertions(+), 4 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
index 1aec07b2e..63f346c59 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
@@ -203,10 +203,14 @@ public class AiRunService {
 
         // Prepared at admission, not on the worker: an unusable workspace 
configuration is an operator
         // error the caller should hear about now, and a degraded conversation 
must say so in its first
-        // persisted event rather than in a log line nobody reads.
+        // persisted event rather than in a log line nobody reads. The http 
engine is the exception: it
+        // has no agent CLI and never reads the workspace, so preparing one 
would let a broken tools
+        // configuration fail - and a missing one warn on every turn of - a 
plain completion.
+        boolean httpEngine = AiRunExecutor.isHttpEngine(engine);
         RmqctlWorkspace.Preparation preparation;
         try {
-            preparation = workspace.prepare(conversation.getId(), 
conversation.getInstanceId()).orElse(null);
+            preparation = httpEngine ? null
+                    : workspace.prepare(conversation.getId(), 
conversation.getInstanceId()).orElse(null);
         } catch (RuntimeException exception) {
             // The row is already inserted and would otherwise stay QUEUED 
with no owner: every later
             // message would be refused 409 until the orphan sweep reaps it. 
Finalize it here — through
@@ -232,9 +236,13 @@ public class AiRunService {
             // is persisted, and it reaches the timeline as a thinking block 
with source=enhance — which
             // is where it belongs, since merging it into the model's 
reasoning is the bug this design
             // exists to prevent.
+            // One verdict, used by both the persisted row and the live frame 
below: an http-engine
+            // turn has no workspace to be degraded about, so it gets neither.
+            TimelineEvent.Notice degraded =
+                    preparation == null && !httpEngine ? 
degradedNotice(conversation) : null;
             int admissionSeq = sink.writeUser(message, null);
-            if (preparation == null) {
-                admissionSeq = 
sink.writeTimeline(degradedNotice(conversation));
+            if (degraded != null) {
+                admissionSeq = sink.writeTimeline(degraded);
             }
             applyTurnToConversation(conversation, message, request.mode());
             session.noteWatermark(admissionSeq);
@@ -242,6 +250,14 @@ public class AiRunService {
             session.finishReplay();
             registry.publish(run.getId(), 0L, new 
LiveEvent.RunStarted(run.getId(), conversation.getId(),
                     conversation.getTitle(), turn));
+            if (degraded != null) {
+                // The persisted notice has to reach the wire too: a client 
that watches the turn
+                // live must see the same warning a reload shows, which is the 
live/replay
+                // equivalence the projector promises. seq 0, because the row 
already carried the
+                // notice and the observer noted that watermark above.
+                registry.publish(run.getId(), 0L,
+                        new LiveEvent.Notice(degraded.level(), 
degraded.message()));
+            }
             runExecutor.submit(context);
         } catch (RejectedExecutionException exception) {
             log.warn("agent run {} was rejected: the run executor is 
saturated", run.getId());
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
index 984273bab..7a73a69ce 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
@@ -49,6 +49,7 @@ import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -56,6 +57,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -215,6 +217,20 @@ class AiRunServiceTest {
         assertThat(inserted.get(1).getSeq()).isEqualTo(2);
     }
 
+    @Test
+    void aDegradedConversationShouldTellTheLiveStreamTooTest() {
+        when(workspace.prepare(anyLong(), any())).thenReturn(Optional.empty());
+
+        service.sendMessage(CONVERSATION_ID, 
AiRunService.RunRequest.of("hello"));
+
+        // Live and replayed must render alike: the notice is persisted as the 
run's first event, so
+        // the observer that is still attached has to hear it as well - not 
only whoever reloads
+        // after the fact.
+        assertThat(AiRunTestSupport.typesOf(inserted)).startsWith("user", 
"notice");
+        
assertThat(emitters.get(0).eventCount("\"type\":\"notice\"")).isEqualTo(1);
+        
assertThat(emitters.get(0).eventText()).contains(RmqctlWorkspace.DEGRADED_NOTICE_MESSAGE);
+    }
+
     @Test
     void 
aConversationWithNoInstanceShouldSayItIsUnboundRatherThanDegradedTest() {
         conversation.setInstanceId(null);
@@ -225,6 +241,29 @@ class AiRunServiceTest {
         
assertThat(inserted.get(1).getPayload()).contains(RmqctlWorkspace.UNBOUND_NOTICE_MESSAGE);
     }
 
+    @Test
+    void anHttpTurnShouldNotPrepareAnAgentWorkspaceItCannotUseTest() {
+        when(llmClient.supports(any())).thenReturn(true);
+        doAnswer(invocation -> {
+            Consumer<String> tokens = invocation.getArgument(3);
+            tokens.accept("pong");
+            return null;
+        }).when(llmClient).stream(any(), any(), any(), any());
+        when(workspace.prepare(anyLong(), any()))
+                .thenThrow(new BusinessException(500, "the workspace directory 
is unusable"));
+
+        service.sendMessage(CONVERSATION_ID, new AiRunService.RunRequest(
+                "hello", null, LlmConfigVO.ENGINE_HTTP, null, false, null));
+
+        // A completion turn has no agent CLI, so it never reads the 
workspace: a broken tools
+        // configuration must not fail it, and an unbound conversation must 
not be warned about
+        // tools this engine never had.
+        verify(workspace, never()).prepare(anyLong(), any());
+        assertThat(AiRunTestSupport.typesOf(inserted)).containsExactly("user", 
"text", "run_status");
+        assertThat(runUpdates.get(runUpdates.size() - 1).getStatus())
+                .isEqualTo(RunStatus.COMPLETED.name());
+    }
+
     @Test
     void 
theFirstTurnShouldNameTheConversationAndALaterOneShouldNotRenameItTest() {
         service.sendMessage(CONVERSATION_ID, AiRunService.RunRequest.of("why 
is my consumer lagging"));

Reply via email to