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 4c546ad6c fix(ai): fail a run whose agent stream ends without a result
frame (#6391)
4c546ad6c is described below
commit 4c546ad6cf7bba8c13c8d0de594bd463dddd6917
Author: 0 <[email protected]>
AuthorDate: Thu Oct 15 20:20:15 2026 +0800
fix(ai): fail a run whose agent stream ends without a result frame (#6391)
* fix(ai): fail a run whose agent stream ends without a result frame
* test(ai): close the flush-failure turn with a success frame
aDatabaseFailureInsideAFlushShouldNotKillTheRunTest emitted only a
TextDelta and then asserted COMPLETED. Under the new no-result branch in
decide() that assertion flips to FAILED, so the case stopped proving what
it was written for: a database failure inside a flush must not kill the
run. Emit a ResultMeta success frame, the same way the recovered-run case
in AiRunServiceTest now does, and keep the COMPLETED assertion. The run
under test is a healthy turn whose event writes fail, not a stream that
ended with no result frame.
---------
Co-authored-by: lizhimins <[email protected]>
---
.../studio/ops/ai/conversation/AiRunExecutor.java | 10 ++++++++
.../ops/ai/conversation/AiRunExecutorTest.java | 5 ++++
.../ops/ai/conversation/AiRunServiceTest.java | 28 ++++++++++++++++++++--
3 files changed, 41 insertions(+), 2 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 db71840c7..400c703b3 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
@@ -126,6 +126,8 @@ public class AiRunExecutor {
static final String ERROR_CODE_INTERNAL = "ai.run.internal_error";
private static final String PROVIDER_TIMEOUT_CODE = "llm.provider.timeout";
+ /** The agent stream closed without ever delivering a result frame. */
+ static final String ERROR_CODE_NO_RESULT = "llm.provider.no_result";
private static final Set<String> OUTPUT_LIMIT_CODES =
Set.of("llm.provider.output_too_large",
"llm.provider.response_too_large");
private static final String ERROR_MAX_TURNS_SUBTYPE = "error_max_turns";
@@ -503,6 +505,14 @@ public class AiRunExecutor {
"llm.provider." + outcome.subtype,
"agent run ended with subtype: " + outcome.subtype,
outcome, false);
}
+ if (!isHttpEngine(context.getEngine()) &&
!StringUtils.hasText(outcome.subtype)) {
+ // The agent stream closed without ever delivering a result frame:
nothing was projected,
+ // nothing failed with a frame of its own — the provider process
simply went away mid-turn.
+ // A turn with no answer is not a successful one. HTTP completion
turns never carry a result
+ // frame at all, so their fall through to COMPLETED stays correct
— that is the exclusion here.
+ return new Terminal(RunStatus.FAILED, StopReason.PROVIDER_ERROR,
ERROR_CODE_NO_RESULT,
+ "agent stream ended without a result frame", outcome,
false);
+ }
return new Terminal(RunStatus.COMPLETED, null, null, null, outcome,
false);
}
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 ecda29e5d..c423a7d51 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
@@ -454,6 +454,11 @@ class AiRunExecutorTest {
doThrow(new RuntimeException("deadlock found when trying to get lock"))
.when(eventRepository).insert(any(RmqAiEvent.class));
provider.emit(new AgentEvent.TextDelta("answer"));
+ // Close the turn with a success frame. This case is about a failing
flush, not about a stream
+ // that ends with no result frame at all — decide() now fails the
latter on purpose, so without
+ // this frame the COMPLETED assertion below would be testing the wrong
branch.
+ provider.emit(new AgentEvent.ResultMeta("session-9", 1200L, 30, 90,
+ AgentEventProjector.SUCCESS_SUBTYPE));
startAndRun();
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 e2a84ce3e..33aa3cd79 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
@@ -25,6 +25,8 @@ import org.apache.rocketmq.studio.ops.ai.LlmConfigVO;
import org.apache.rocketmq.studio.ops.ai.OpenAiCompatibleLlmClient;
import org.apache.rocketmq.studio.ops.ai.conversation.agent.PromptEnhancer;
import org.apache.rocketmq.studio.ops.ai.conversation.agent.RmqctlWorkspace;
+import org.apache.rocketmq.studio.ops.ai.conversation.event.AgentEvent;
+import
org.apache.rocketmq.studio.ops.ai.conversation.event.AgentEventProjector;
import org.apache.rocketmq.studio.ops.ai.conversation.event.LiveEvent;
import org.apache.rocketmq.studio.ops.ai.conversation.event.RunStatus;
import org.apache.rocketmq.studio.ops.ai.conversation.event.StopReason;
@@ -170,8 +172,11 @@ class AiRunServiceTest {
// The worker picked the run up: QUEUED is a real state, not a label
the row keeps.
assertThat(runUpdates.get(0).getStatus()).isEqualTo(RunStatus.RUNNING.name());
assertThat(runUpdates.get(0).getStartedAt()).isNotNull();
- // ...and the run really did go on to execute and finish on the queued
engine.
-
assertThat(lastRun().getStatus()).isEqualTo(RunStatus.COMPLETED.name());
+ // ...and the run really did go on to execute and reach a terminal on
the queued engine —
+ // a terminal, not specifically a success: this stub streams no
frames, so the turn ends
+ // with the no-result failure instead of a success nothing projected.
+ assertThat(lastRun().getStatus()).isEqualTo(RunStatus.FAILED.name());
+
assertThat(lastRun().getStopReason()).isEqualTo(StopReason.PROVIDER_ERROR.name());
assertThat(emitters.get(0).timeoutMillis()).isEqualTo(AiRunExecutor.CLI_STREAM_TIMEOUT_MILLIS);
}
@@ -326,11 +331,30 @@ class AiRunServiceTest {
// 409 the stranded-QUEUED row produced for up to a day.
org.mockito.Mockito.reset(workspace);
when(workspace.prepare(anyLong(),
any())).thenReturn(Optional.of(preparation()));
+ // Script a success frame so the recovered run completes: an
unscripted stub stream now
+ // ends with the no-result failure, and this turn is meant to be a
healthy one.
+ provider.emit(new AgentEvent.ResultMeta("session-2", 10L, 3, 4,
+ AgentEventProjector.SUCCESS_SUBTYPE));
service.sendMessage(CONVERSATION_ID,
AiRunService.RunRequest.of("second try"));
assertThat(runInserts).hasSize(2);
assertThat(lastRun().getStatus()).isEqualTo(RunStatus.COMPLETED.name());
}
+ @Test
+ void aStreamThatEndsWithoutAResultFrameMustNotCompleteTheRunTest() {
+ // The stub streams no frames at all: the agent stream closes without
a result frame, so
+ // nothing was projected and nothing errored with a frame of its own —
the provider process
+ // simply went away mid-turn. Reporting COMPLETED would present a turn
with no answer as a
+ // successful one. HTTP completion turns never carry a result frame
and are deliberately
+ // excluded from this in decide().
+ service.sendMessage(CONVERSATION_ID,
AiRunService.RunRequest.of("anybody home"));
+
+ assertThat(lastRun().getStatus()).isEqualTo(RunStatus.FAILED.name());
+
assertThat(lastRun().getStopReason()).isEqualTo(StopReason.PROVIDER_ERROR.name());
+
assertThat(lastRun().getErrorCode()).isEqualTo(AiRunExecutor.ERROR_CODE_NO_RESULT);
+ assertThat(registry.isLive(RUN_ID)).isFalse();
+ }
+
@Test
void aBlankOrOversizedMessageShouldBeRefusedBeforeARowIsWrittenTest() {
assertThatThrownBy(() -> service.sendMessage(CONVERSATION_ID,
AiRunService.RunRequest.of(" ")))