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 8171c2a1d fix(ai): keep a projected success terminal over a later stop 
(#6385)
8171c2a1d is described below

commit 8171c2a1de5b26844225bdc7fc4c183e2a639d73
Author: 0 <[email protected]>
AuthorDate: Thu Oct 15 20:18:36 2026 +0800

    fix(ai): keep a projected success terminal over a later stop (#6385)
    
    * fix(ai): keep a projected success terminal over a later stop
    
    decide() let an abort win unconditionally, so a stop that landed after
    the success frame had already been projected - the run_status row, the
    live run_finished, everything - wrote a second terminal event into the
    timeline and rewrote the row to STOPPED for an answer the user watched
    complete. The transcript rendered a phantom empty stopped bubble behind
    the finished one.
    
    The projected success is now checked first: once the projector has
    persisted the terminal, finalisation honours it exactly-once. An abort
    that landed before the success frame still wins, which is the
    stopRacedSuccess case where the frame was swallowed and nothing terminal
    was projected.
    
    * test(ai): reuse StubAgentProvider via an afterEvent hook and share the 
properties helper
---
 .../studio/ops/ai/conversation/AiRunExecutor.java  | 17 +++++---
 .../ops/ai/conversation/AiRunExecutorTest.java     | 49 ++++++++++++++++++++--
 .../ops/ai/conversation/AiRunTestSupport.java      | 13 +++++-
 3 files changed, 69 insertions(+), 10 deletions(-)

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 7d6b46839..db71840c7 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
@@ -468,19 +468,24 @@ public class AiRunExecutor {
         }
     }
 
-    /** Maps what happened onto the terminal state to write. An abort always 
wins: it is the decision
-     * a human or the server made about this run, and a provider frame that 
arrived afterwards does not
-     * unmake it. */
+    /**
+     * Maps what happened onto the terminal state to write. A success frame 
that was already projected
+     * wins over everything: the projector persisted the terminal {@code 
run_status} and published the
+     * live {@code run_finished} at that moment, so any abort that lands 
afterwards (a user stop that
+     * raced the finalisation, say) must not write a second terminal event 
over a finished run. An
+     * abort that landed <em>before</em> the success frame still wins - that 
is the
+     * {@code stopRacedSuccess} case, where the frame was swallowed and 
nothing terminal was projected.
+     */
     private Terminal decide(RunContext context, Outcome outcome) {
+        if (outcome.successTerminalProjected) {
+            return new Terminal(RunStatus.COMPLETED, null, null, null, 
outcome, true);
+        }
         AbortReason abort = context.getHandle() == null
                 ? null
                 : context.getHandle().abortReason().orElse(null);
         if (abort != null) {
             return new Terminal(abort.status(), abort.stopReason(), null, 
null, outcome, false);
         }
-        if (outcome.successTerminalProjected) {
-            return new Terminal(RunStatus.COMPLETED, null, null, null, 
outcome, true);
-        }
         if (outcome.gatewayFailure != null) {
             LlmGatewayException failure = outcome.gatewayFailure;
             return new Terminal(RunStatus.FAILED, 
stopReasonForCode(failure.getCode()), failure.getCode(),
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 ff47659a8..ecda29e5d 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
@@ -149,10 +149,8 @@ class AiRunExecutorTest {
                     : "conversation:last_seq=" + update.getLastSeq());
             return null;
         }).when(conversationRepository).update(any(RmqAiConversation.class));
-        AiConversationProperties properties = new AiConversationProperties();
-        properties.setStopGrace(Duration.ofMillis(1));
         executor = new AiRunExecutor(new 
AgentProviderRegistry(List.of(provider)), llmClient, promptEnhancer,
-                registry, runRepository, conversationRepository, 
eventRepository, properties, objectMapper,
+                registry, runRepository, conversationRepository, 
eventRepository, properties(), objectMapper,
                 AiRunTestSupport.directExecutor(), 
AiRunTestSupport.recordingInto(emitters), scheduler,
                 Clock.systemUTC());
         conversation = AiRunTestSupport.conversation(CONVERSATION_ID, 
"tester");
@@ -218,6 +216,45 @@ class AiRunExecutorTest {
         assertThat(registry.isLive(RUN_ID)).isFalse();
     }
 
+    @Test
+    void 
aStopThatLandsAfterTheSuccessFrameMustNotOverwriteTheCompletedRunTest() {
+        // The success frame is projected the moment it arrives - the 
run_status row, the live
+        // run_finished, everything. A stop that lands after it (the user 
pressed the button while
+        // the answer was already being finalised) can only find a finished 
run: writing a second
+        // terminal would corrupt the timeline with two run_status events and 
rewrite the row to
+        // STOPPED for an answer the user watched complete.
+        // The stop must land between the success frame and the projector's 
terminal write — inside
+        // the stream, not before the script. The stub's afterEvent hook fires 
exactly there: one
+        // scripted success frame, then requestStop runs with the frame's own 
projection already done.
+        AiRunTestSupport.StubAgentProvider lateStop = new 
AiRunTestSupport.StubAgentProvider();
+        lateStop.emit(new AgentEvent.ResultMeta("session-7", 10L, 3, 4,
+                AgentEventProjector.SUCCESS_SUBTYPE));
+        lateStop.afterEvent = (event, options) -> ((AgentRunHandle) 
options.getProcessSink())
+                .requestStop(AbortReason.USER_STOP);
+        AiRunExecutor lateExecutor = new AiRunExecutor(new 
AgentProviderRegistry(List.of(lateStop)),
+                llmClient, promptEnhancer, registry, runRepository, 
conversationRepository,
+                eventRepository, properties(), objectMapper, 
AiRunTestSupport.directExecutor(),
+                AiRunTestSupport.recordingInto(emitters), scheduler, 
Clock.systemUTC());
+        AgentRunHandle handle = lateExecutor.newHandle(RUN_ID);
+        AiEventSink sink = lateExecutor.newSink(CONVERSATION_ID, RUN_ID, 1, 0);
+        session = lateExecutor.newSession(RUN_ID, 
lateExecutor.streamTimeoutMillis(ENGINE));
+        registry.register(RUN_ID, handle);
+        sink.writeUser("hello", null);
+        registry.attach(RUN_ID, session);
+        session.finishReplay();
+        lateExecutor.submit(new AiRunExecutor.RunContext(conversation, run, 
sink, handle,
+                
LlmConfigVO.builder().engine(ENGINE).model("qwen3.8-max").enabled(true).build(),
+                ENGINE, "hello", null, false, Duration.ofSeconds(300), null));
+
+        assertThat(runRow().getStatus()).isEqualTo(RunStatus.COMPLETED.name());
+        assertThat(runRow().getStopReason()).isNull();
+        // Exactly one terminal event, and it is the completed one the 
projector already wrote.
+        assertThat(types()).containsExactly("user", "run_status");
+        assertThat(payloads().get(1)).contains(RunStatus.COMPLETED.name());
+        
assertThat(emitters.get(0).eventCount("\"type\":\"run_finished\"")).isEqualTo(1);
+        assertThat(registry.isLive(RUN_ID)).isFalse();
+    }
+
     @Test
     void 
aStopDuringTheRunShouldWriteStoppedEvenWhenTheProviderReportsSuccessTest() {
         // claude flushes its result frame on SIGTERM, so this is the normal 
shape of a stop that lands
@@ -553,6 +590,12 @@ class AiRunExecutorTest {
 
     // --- harness 
---------------------------------------------------------------
 
+    private AiConversationProperties properties() {
+        AiConversationProperties properties = new AiConversationProperties();
+        properties.setStopGrace(Duration.ofMillis(1));
+        return properties;
+    }
+
     private void startAndRun() {
         startAndRun(false);
     }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunTestSupport.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunTestSupport.java
index 23860b958..412c33101 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunTestSupport.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunTestSupport.java
@@ -38,6 +38,7 @@ import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
 import java.util.function.Consumer;
 import java.util.function.LongFunction;
 
@@ -274,6 +275,13 @@ final class AiRunTestSupport {
         final List<AgentEvent> scripted = new ArrayList<>();
         Consumer<AgentStreamOptions> beforeStream = options -> {
         };
+        /**
+         * Runs after each scripted frame reaches the sink, with the stream's 
options in hand — the
+         * hook for a test whose stop must land <em>between</em> frames, which 
{@code beforeStream}
+         * (it fires before the script) cannot express.
+         */
+        BiConsumer<AgentEvent, AgentStreamOptions> afterEvent = (event, 
options) -> {
+        };
         RuntimeException failure;
         /**
          * Thrown instead of any RuntimeException, so a test can reach the 
paths that only an {@link Error}
@@ -313,7 +321,10 @@ final class AiRunTestSupport {
             lastPrompt = options.getPrompt();
             lastOptions = options;
             beforeStream.accept(options);
-            scripted.forEach(sink);
+            scripted.forEach(event -> {
+                sink.accept(event);
+                afterEvent.accept(event, options);
+            });
             if (error != null) {
                 throw error;
             }

Reply via email to