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 02d81ec28 fix(ai): retry a turn whose remembered agent session is gone
(#4693)
02d81ec28 is described below
commit 02d81ec286b068f1ec42c9827d970f52dcc63c55
Author: Apulupie <[email protected]>
AuthorDate: Mon Sep 21 21:09:03 2026 +0800
fix(ai): retry a turn whose remembered agent session is gone (#4693)
`ResumeRecovery` documented the lost-resume case in detail but had no
production caller. `RmqctlWorkspace` keeps the agent workspace under `/tmp`, so
a container restart wipes it while `conversation.runtime_session_id` survives
in MySQL; the CLI then exits non-zero with "No conversation found with session
ID" and emits only a result frame whose subtype is `error_during_execution`,
echoing the requested id back. `AiRunExecutor.record` trusted
`ResultMeta.runtimeSessionId()` uncondition [...]
`ClaudeCodeStreamParser` now captures the result subtype and
`ClaudeCodeAgentProvider.streamEvents` raises an `LlmGatewayException` carrying
`ResumeRecovery.RESUME_LOST_CODE` when a resumed run reports it.
`AiRunExecutor.streamWithLostResumeRetry` catches that code, forgets the
session through the new `clearRuntimeSessionId` — needed because `updateById`'s
NOT_NULL field strategy cannot express a null — discards the first attempt's
outcome fields, emits a warn `ProviderNotice` and re- [...]
Fixes #4694
---
.../studio/ops/ai/ClaudeCodeAgentProvider.java | 25 +++++-
.../studio/ops/ai/ClaudeCodeStreamParser.java | 15 +++-
.../ai/conversation/AiConversationRepository.java | 6 ++
.../studio/ops/ai/conversation/AiRunExecutor.java | 98 +++++++++++++++++++---
.../MybatisPlusAiConversationRepository.java | 12 +++
.../ops/ai/conversation/agent/ResumeRecovery.java | 12 +++
.../studio/ops/ai/ClaudeCodeAgentProviderTest.java | 41 +++++++++
.../ops/ai/conversation/AiRunExecutorTest.java | 86 ++++++++++++++++++-
.../ai/conversation/AiTimelineRepositoryTest.java | 34 ++++++++
9 files changed, 315 insertions(+), 14 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
index 606983824..08c3d1a3d 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.ops.ai;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.studio.ops.ai.conversation.agent.AgentStreamOptions;
+import org.apache.rocketmq.studio.ops.ai.conversation.agent.ResumeRecovery;
import org.apache.rocketmq.studio.ops.ai.conversation.event.AgentEvent;
import
org.apache.rocketmq.studio.ops.ai.conversation.event.AgentEventProjector;
import org.springframework.stereotype.Component;
@@ -180,7 +181,8 @@ public class ClaudeCodeAgentProvider extends
CliAgentProvider {
* <p>A non-zero exit is only an error when the CLI never produced its
terminal {@code result}
* frame. When it did, the frame already said what happened — {@code
error_max_turns}, an
* {@code api_error_status}, a permission denial — and the parser turned
it into events, so
- * throwing here would replace a precise diagnosis with a generic one.
+ * throwing here would replace a precise diagnosis with a generic one. The
single exception is a
+ * {@code --resume} session that no longer exists: see {@link
#throwIfTheResumeSessionWasLost}.
*/
@Override
public void streamEvents(LlmConfigVO config, AgentStreamOptions options,
Consumer<AgentEvent> sink) {
@@ -199,6 +201,7 @@ public class ClaudeCodeAgentProvider extends
CliAgentProvider {
SpawnResult spawn = spawn(command, childEnv(config, options),
timeoutSeconds,
options.getWorkspaceDir(), line ->
parser.parseLine(line).forEach(sink),
options.getProcessSink());
+ throwIfTheResumeSessionWasLost(options, parser, spawn);
if (spawn.exitCode() != 0 && !parser.resultFrameSeen()) {
throw new LlmGatewayException(502, "llm.provider.cli_error",
BINARY + " CLI failed: " + describeStderr(spawn.stderr()),
@@ -206,6 +209,26 @@ public class ClaudeCodeAgentProvider extends
CliAgentProvider {
}
}
+ /**
+ * Reports the one failure a caller can repair by retrying: the
conversation's {@code --resume}
+ * session is gone. The frames have already said so, so this adds no
diagnosis — what the caller
+ * cannot know on its own is that the retry has to drop {@code --resume},
and the command is built
+ * here. {@link ResumeRecovery} holds the contract.
+ *
+ * <p>Both halves of the signal are needed: the stderr line is a
human-readable string a CLI
+ * upgrade may reword, and {@code error_during_execution} also reports
failures no retry can fix.
+ */
+ private void throwIfTheResumeSessionWasLost(AgentStreamOptions options,
+ ClaudeCodeStreamParser parser,
SpawnResult spawn) {
+ if
(!ResumeRecovery.shouldRetryWithoutResume(StringUtils.hasText(options.getResumeSessionId()),
+ spawn.exitCode(), parser.resultSubtype(), spawn.stderr())) {
+ return;
+ }
+ throw new LlmGatewayException(502, ResumeRecovery.RESUME_LOST_CODE,
+ "the session conversation resumed no longer exists: " +
describeStderr(spawn.stderr()),
+ "The turn is retried once without --resume; the earlier turns'
context is lost.");
+ }
+
/**
* Streams completion tokens as plain text.
*
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParser.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParser.java
index 552bb9da4..fa40f9bf6 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParser.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeStreamParser.java
@@ -221,6 +221,9 @@ final class ClaudeCodeStreamParser {
/** True once the terminal {@code result} frame arrived. */
private boolean resultFrameSeen;
+ /** Subtype of that frame, resolved by {@link #resolveSubtype}; null while
none has arrived. */
+ private String resultSubtype;
+
/** Session id from the most recent frame that carried one; the init frame
is the first. */
private String runtimeSessionId;
@@ -288,6 +291,15 @@ final class ClaudeCodeStreamParser {
return resultFrameSeen;
}
+ /**
+ * The subtype of that frame, or null when none arrived. One half of the
lost-resume signal: the
+ * caller pairs it with the exit code and the stderr, because a subtype
alone cannot tell a
+ * vanished {@code --resume} session from a failure no retry can fix.
+ */
+ String resultSubtype() {
+ return resultSubtype;
+ }
+
/**
* Every frame of a run carries {@code session_id}, including {@code
system/init} and
* {@code stream_event}, so the id is known before anything interesting
happens.
@@ -558,10 +570,11 @@ final class ClaudeCodeStreamParser {
}
}
resultFrameSeen = true;
+ resultSubtype = resolveSubtype(node);
JsonNode usage = node.path("usage");
events.add(new AgentEvent.ResultMeta(runtimeSessionId,
longOrNull(node, "duration_ms"),
intOrNull(usage, "input_tokens"), intOrNull(usage,
"output_tokens"),
- resolveSubtype(node)));
+ resultSubtype));
return events;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationRepository.java
index 1fa3dd477..98580081f 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationRepository.java
@@ -45,6 +45,12 @@ public interface AiConversationRepository {
void update(RmqAiConversation conversation);
+ /**
+ * Forgets the remembered {@code --resume} session id. A method of its own
because {@link #update}
+ * writes non-null fields only, so it cannot express "forget this value".
+ */
+ void clearRuntimeSessionId(Long id);
+
int deleteById(Long id);
/** Ids created strictly before the cutoff, oldest first, capped at limit.
*/
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 e2d0c4d86..756d51895 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
@@ -27,6 +27,7 @@ import org.apache.rocketmq.studio.ops.ai.LlmGatewayException;
import org.apache.rocketmq.studio.ops.ai.OpenAiCompatibleLlmClient;
import org.apache.rocketmq.studio.ops.ai.conversation.agent.AgentStreamOptions;
import org.apache.rocketmq.studio.ops.ai.conversation.agent.PromptEnhancer;
+import org.apache.rocketmq.studio.ops.ai.conversation.agent.ResumeRecovery;
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;
@@ -275,7 +276,7 @@ public class AiRunExecutor {
Outcome outcome = new Outcome();
try {
markRunning(context);
- stream(context, outcome);
+ streamWithLostResumeRetry(context, outcome);
} catch (LlmGatewayException exception) {
log.warn("agent run {} failed: {} - {}", run.getId(),
exception.getCode(), exception.getMessage());
outcome.gatewayFailure = exception;
@@ -304,20 +305,57 @@ public class AiRunExecutor {
run.getId(), run.getConversationId(), run.getTurn(),
run.getEngine());
}
- private void stream(RunContext context, Outcome outcome) {
- String prompt = context.getPrompt();
- if (context.isEnhance()) {
- prompt = promptEnhancer.enhance(context.getConfig(),
context.getEngine(), prompt,
- chunk -> onAgentEvent(context, outcome,
- new AgentEvent.ThinkingDelta(chunk,
ThinkingSource.ENHANCE)));
+ /**
+ * Streams the turn, and repairs the one provider failure a retry can
repair: the {@code --resume}
+ * session the conversation remembers no longer exists on disk. The
provider reports it as
+ * {@link ResumeRecovery#RESUME_LOST_CODE}, because only the provider
knows the retry has to drop
+ * {@code --resume} from the command.
+ *
+ * <p>Exactly one retry, and the dead id is forgotten <em>before</em> it:
were it kept, the retry
+ * would hit the same wall and so would every turn after it. What the
conversation loses is the
+ * earlier turns' context, which is the price {@link ResumeRecovery}
documents.
+ */
+ private void streamWithLostResumeRetry(RunContext context, Outcome
outcome) {
+ // Prepared once, outside the retry: re-running the enhancer would pay
for the rewrite twice
+ // and put its reasoning in the timeline a second time.
+ String prompt = preparePrompt(context, outcome);
+ boolean resumeRequested =
StringUtils.hasText(context.getResumeSessionId());
+ try {
+ stream(context, prompt, outcome, resumeRequested);
+ } catch (LlmGatewayException exception) {
+ if (!resumeRequested ||
!ResumeRecovery.RESUME_LOST_CODE.equals(exception.getCode())) {
+ throw exception;
+ }
+ log.warn("agent run {} could not resume the session conversation
{} remembers; retrying"
+ + " without --resume",
+ context.getRun().getId(),
context.getConversation().getId());
+ forgetRuntimeSession(context);
+ outcome.forgetFirstAttempt();
+ context.getSink().emit(new
AgentEvent.ProviderNotice(AgentEventProjector.LEVEL_WARN,
+ "The agent session this conversation was resuming no
longer exists; the turn was"
+ + " retried without the earlier turns' context."));
+ stream(context, prompt, outcome, false);
}
+ }
+
+ /** The prompt as the provider receives it, after the optional rewrite. */
+ private String preparePrompt(RunContext context, Outcome outcome) {
+ if (!context.isEnhance()) {
+ return context.getPrompt();
+ }
+ return promptEnhancer.enhance(context.getConfig(),
context.getEngine(), context.getPrompt(),
+ chunk -> onAgentEvent(context, outcome,
+ new AgentEvent.ThinkingDelta(chunk,
ThinkingSource.ENHANCE)));
+ }
+
+ private void stream(RunContext context, String prompt, Outcome outcome,
boolean allowResume) {
Consumer<AgentEvent> events = event -> onAgentEvent(context, outcome,
event);
if (isHttpEngine(context.getEngine())) {
streamHttp(context, prompt, events);
return;
}
agentProviders.forEngine(context.getEngine())
- .streamEvents(context.getConfig(), options(context, prompt),
events);
+ .streamEvents(context.getConfig(), options(context, prompt,
allowResume), events);
}
/**
@@ -337,12 +375,13 @@ public class AiRunExecutor {
token -> events.accept(new AgentEvent.TextDelta(token)));
}
- private AgentStreamOptions options(RunContext context, String prompt) {
+ private AgentStreamOptions options(RunContext context, String prompt,
boolean allowResume) {
RmqctlWorkspace.Preparation preparation = context.getPreparation();
AgentStreamOptions.AgentStreamOptionsBuilder builder =
AgentStreamOptions.builder()
.prompt(prompt)
.model(context.getRun().getModel())
- .resumeSessionId(context.getResumeSessionId())
+ // Dropped for the retry: that is the whole repair.
+ .resumeSessionId(allowResume ? context.getResumeSessionId() :
null)
.instanceId(context.getConversation().getInstanceId())
// Registered so a stop kills the real process tree instead of
relying on an interrupt.
.processSink(context.getHandle())
@@ -397,7 +436,11 @@ public class AiRunExecutor {
return;
}
if (event instanceof AgentEvent.ResultMeta meta) {
- if (StringUtils.hasText(meta.runtimeSessionId())) {
+ // Only from a successful frame: a failed one echoes the
*requested* session id back, so
+ // persisting it would point the next turn at a session that does
not exist. The init
+ // frame's id, taken above, is a real one and survives a later
failure.
+ if (AgentEventProjector.SUCCESS_SUBTYPE.equals(meta.subtype())
+ && StringUtils.hasText(meta.runtimeSessionId())) {
outcome.runtimeSessionId = meta.runtimeSessionId().trim();
}
if (meta.inputTokens() != null) {
@@ -528,6 +571,24 @@ public class AiRunExecutor {
}
}
+ /**
+ * Drops the {@code --resume} id the conversation remembers, so the retry
— and every turn after it
+ * — starts a fresh CLI session instead of failing on the same dead one
again.
+ */
+ private void forgetRuntimeSession(RunContext context) {
+ RmqAiConversation conversation = context.getConversation();
+ if (!StringUtils.hasText(conversation.getRuntimeSessionId())) {
+ return;
+ }
+ try {
+ conversationRepository.clearRuntimeSessionId(conversation.getId());
+ conversation.setRuntimeSessionId(null);
+ } catch (RuntimeException exception) {
+ log.warn("could not forget the runtime session id of conversation
{}: {}",
+ conversation.getId(), exception.toString());
+ }
+ }
+
private long durationMs(RmqAiRun run, LocalDateTime finishedAt) {
LocalDateTime startedAt = run.getStartedAt() != null ?
run.getStartedAt() : run.getGmtCreate();
if (startedAt == null) {
@@ -664,6 +725,21 @@ public class AiRunExecutor {
private boolean stopRacedSuccess;
private LlmGatewayException gatewayFailure;
private RuntimeException unexpected;
+
+ /**
+ * Drops everything the attempt that asked for the missing session
reported, so the retry's own
+ * frames decide the terminal state. Its session id in particular must
not survive: it is the
+ * dead one, echoed back.
+ */
+ void forgetFirstAttempt() {
+ runtimeSessionId = null;
+ subtype = null;
+ inputTokens = null;
+ outputTokens = null;
+ providerDurationMs = null;
+ successTerminalProjected = false;
+ stopRacedSuccess = false;
+ }
}
/** The terminal state to write, and whether the projector already wrote
one. */
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/MybatisPlusAiConversationRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/MybatisPlusAiConversationRepository.java
index be88197d6..3448dcf46 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/MybatisPlusAiConversationRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/MybatisPlusAiConversationRepository.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.ops.ai.conversation;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.apache.rocketmq.studio.common.domain.PageResult;
@@ -79,6 +80,17 @@ public class MybatisPlusAiConversationRepository implements
AiConversationReposi
conversationMapper.updateById(conversation);
}
+ @Override
+ public void clearRuntimeSessionId(Long id) {
+ if (id == null) {
+ return;
+ }
+ // updateById skips null fields, so "forget this value" has to name
the column itself.
+ conversationMapper.update(null, new UpdateWrapper<RmqAiConversation>()
+ .eq("id", id)
+ .set("runtime_session_id", null));
+ }
+
@Override
public int deleteById(Long id) {
return id == null ? 0 : conversationMapper.deleteById(id);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/ResumeRecovery.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/ResumeRecovery.java
index 7481f916e..d2a3d0704 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/ResumeRecovery.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/ResumeRecovery.java
@@ -46,9 +46,21 @@ import org.springframework.util.StringUtils;
* </ol>
* Losing the earlier turns' context is the price; without the retry the
conversation is permanently
* broken, because every subsequent turn would resume the same missing id.
+ *
+ * <p>The signal is detected where the exit code and the stderr are still in
scope — the provider that
+ * spawned the CLI — and travels to the caller as {@link #RESUME_LOST_CODE} on
a
+ * {@code LlmGatewayException}, because the frames alone cannot express it:
the command that has to
+ * drop {@code --resume} is the provider's to build.
*/
public final class ResumeRecovery {
+ /**
+ * The error code a provider reports when a run could not resume the
session its conversation
+ * remembers. The caller that sees it owns the recovery: retry the turn
once without
+ * {@code --resume}, after clearing {@code
conversation.runtime_session_id}.
+ */
+ public static final String RESUME_LOST_CODE = "llm.provider.resume_lost";
+
/** The CLI's stderr line, matched as a prefix of the first non-blank
content. */
static final String SESSION_NOT_FOUND_PREFIX = "No conversation found with
session ID:";
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
index be0755bd8..826eeaf44 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.ops.ai;
import org.apache.rocketmq.studio.ops.ai.conversation.agent.AgentStreamOptions;
+import org.apache.rocketmq.studio.ops.ai.conversation.agent.ResumeRecovery;
import org.apache.rocketmq.studio.ops.ai.conversation.event.AgentEvent;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -353,6 +354,46 @@ class ClaudeCodeAgentProviderTest {
new AgentEvent.ResultMeta("s-1", 12L, 3, 4,
"error_max_turns"));
}
+ @Test
+ void streamEventsShouldReportALostResumeSessionTest() {
+ // The measured shape of a stale --resume: exit 1, the
session-not-found line on stderr, and a
+ // result frame whose subtype is error_during_execution with the dead
id echoed back.
+ StreamingTestProvider provider = new StreamingTestProvider(
+ List.of("sh", "-c", "printf '%s' "
+ +
"'{\"type\":\"result\",\"subtype\":\"error_during_execution\",\"is_error\":true,"
+ + "\"num_turns\":0,\"session_id\":\"gone-session\"}'; "
+ + "echo 'No conversation found with session ID:
gone-session' >&2; exit 1"), 30);
+
+ // The caller can only retry correctly if it knows the retry has to
drop --resume, and the
+ // command is built here. See ResumeRecovery.
+ assertThatThrownBy(() ->
provider.streamEvents(LlmConfigVO.builder().build(),
+ AgentStreamOptions.builder().prompt("hi").model("qwen3.8-max")
+ .resumeSessionId("gone-session").build(), event -> {
}))
+ .isInstanceOfSatisfying(LlmGatewayException.class, exception
-> {
+ assertThat(exception.getStatusCode()).isEqualTo(502);
+
assertThat(exception.getCode()).isEqualTo(ResumeRecovery.RESUME_LOST_CODE);
+
assertThat(exception.getMessage()).contains("gone-session");
+ });
+ }
+
+ @Test
+ void streamEventsShouldNotReportALostResumeWhenTheRunNeverResumedTest() {
+ StreamingTestProvider provider = new StreamingTestProvider(
+ List.of("sh", "-c", "printf '%s' "
+ +
"'{\"type\":\"result\",\"subtype\":\"error_during_execution\",\"is_error\":true,"
+ + "\"num_turns\":0,\"session_id\":\"s-1\"}'; "
+ + "echo 'No conversation found with session ID: s-1'
>&2; exit 1"), 30);
+ List<AgentEvent> events = new ArrayList<>();
+
+ // Nothing was resumed, so the signal is not the one a retry repairs:
the frames explain the
+ // failure and retrying the same command would only repeat it.
+ provider.streamEvents(LlmConfigVO.builder().build(),
+
AgentStreamOptions.builder().prompt("hi").model("qwen3.8-max").build(),
events::add);
+
+ assertThat(events).containsExactly(
+ new AgentEvent.ResultMeta("s-1", null, null, null,
"error_during_execution"));
+ }
+
private static int count(List<AgentEvent> events, Class<?> type) {
return (int) events.stream().filter(type::isInstance).count();
}
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 2c3fec1f7..31e1f87c0 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
@@ -22,6 +22,7 @@ import org.apache.rocketmq.studio.ops.ai.LlmConfigVO;
import org.apache.rocketmq.studio.ops.ai.LlmGatewayException;
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.ResumeRecovery;
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.RunStatus;
@@ -272,6 +273,81 @@ class AiRunExecutorTest {
assertThat(runRow().getErrorCode()).isEqualTo("llm.provider.error_max_turns");
}
+ @Test
+ void aFailedResultFrameShouldNotPersistTheSessionIdItEchoesBackTest() {
+ // A failed frame carries the session id that was *requested*, not a
live one: the parser says so
+ // in as many words. Persisting it would point the next turn at a
session that does not exist.
+ provider.emit(new AgentEvent.ResultMeta("echoed-session", 12L, 1, 2,
"error_max_turns"));
+
+ startAndRun();
+
+ assertThat(runRow().getStatus()).isEqualTo(RunStatus.FAILED.name());
+ assertThat(runRow().getRuntimeSessionId()).isNull();
+ assertThat(conversation.getRuntimeSessionId()).isNull();
+ }
+
+ @Test
+ void
aLostResumeSessionShouldBeForgottenAndTheTurnRetriedWithoutResumeTest() {
+ List<String> resumedSessions = new CopyOnWriteArrayList<>();
+ conversation.setRuntimeSessionId("gone-session");
+ // The measured shape of a stale --resume: exit 1, one
error_during_execution result frame with
+ // the dead id echoed back, and the stderr line naming the session.
+ provider.emit(new AgentEvent.ResultMeta("gone-session", null, null,
null, "error_during_execution"));
+ provider.failure = new LlmGatewayException(502,
ResumeRecovery.RESUME_LOST_CODE,
+ "the session conversation resumed no longer exists", "Retried
without --resume.");
+ provider.beforeStream = options -> {
+ resumedSessions.add(options.getResumeSessionId());
+ if (provider.calls > 1) {
+ provider.scripted.clear();
+ provider.failure = null;
+ provider.emit(new AgentEvent.TextDelta("fresh answer"));
+ provider.emit(new AgentEvent.ResultMeta("new-session", 30L, 5,
6,
+ AgentEventProjector.SUCCESS_SUBTYPE));
+ }
+ };
+
+ startAndRun("gone-session");
+
+ // The repair is a second attempt at the same turn, and it is the
whole repair: no --resume on it.
+ assertThat(provider.calls).isEqualTo(2);
+ assertThat(resumedSessions).containsExactly("gone-session", null);
+ assertThat(provider.lastPrompt).isEqualTo("hello");
+ verify(conversationRepository).clearRuntimeSessionId(CONVERSATION_ID);
+ // The retry's session replaces the dead one, and the user is told the
context is gone.
+ assertThat(runRow().getStatus()).isEqualTo(RunStatus.COMPLETED.name());
+ assertThat(runRow().getRuntimeSessionId()).isEqualTo("new-session");
+
assertThat(conversation.getRuntimeSessionId()).isEqualTo("new-session");
+ assertThat(types()).containsExactly("user", "error", "notice", "text",
"run_status");
+ // Exactly one terminal reached the wire: the retry's, not one per
attempt.
+
assertThat(emitters.get(0).eventCount("\"type\":\"run_finished\"")).isEqualTo(1);
+ }
+
+ @Test
+ void aRetryThatFailsTooShouldStillLeaveTheDeadSessionIdForgottenTest() {
+ conversation.setRuntimeSessionId("gone-session");
+ provider.emit(new AgentEvent.ResultMeta("gone-session", null, null,
null, "error_during_execution"));
+ provider.failure = new LlmGatewayException(502,
ResumeRecovery.RESUME_LOST_CODE,
+ "the session conversation resumed no longer exists", "Retried
without --resume.");
+ provider.beforeStream = options -> {
+ if (provider.calls > 1) {
+ provider.scripted.clear();
+ provider.failure = new LlmGatewayException(504,
"llm.provider.timeout",
+ "claude CLI stream timed out after 300s", "Retry with
a shorter prompt.");
+ provider.emit(new AgentEvent.TextDelta("partial"));
+ }
+ };
+
+ startAndRun("gone-session");
+
+ // A retry that fails must not put the dead id back: every turn after
it would otherwise resume a
+ // session that is still gone. Null is the honest state, and the next
turn starts fresh.
+ assertThat(provider.calls).isEqualTo(2);
+ verify(conversationRepository).clearRuntimeSessionId(CONVERSATION_ID);
+ assertThat(runRow().getStatus()).isEqualTo(RunStatus.FAILED.name());
+ assertThat(runRow().getRuntimeSessionId()).isNull();
+ assertThat(conversation.getRuntimeSessionId()).isNull();
+ }
+
@Test
void anUnexpectedProviderFailureShouldNotLeaveTheRunNonTerminalTest() {
provider.failure = new IllegalStateException("provider exploded");
@@ -469,11 +545,19 @@ class AiRunExecutorTest {
executor.submit(context(ENGINE, enhance));
}
+ private void startAndRun(String resumeSessionId) {
+ executor.submit(context(ENGINE, false, resumeSessionId));
+ }
+
private AiRunExecutor.RunContext context(String engine) {
return context(engine, false);
}
private AiRunExecutor.RunContext context(String engine, boolean enhance) {
+ return context(engine, enhance, null);
+ }
+
+ private AiRunExecutor.RunContext context(String engine, boolean enhance,
String resumeSessionId) {
AgentRunHandle handle = executor.newHandle(RUN_ID);
AiEventSink sink = executor.newSink(CONVERSATION_ID, RUN_ID, 1, 0);
session = executor.newSession(RUN_ID,
executor.streamTimeoutMillis(engine));
@@ -483,7 +567,7 @@ class AiRunExecutorTest {
session.finishReplay();
return new AiRunExecutor.RunContext(conversation, run, sink, handle,
LlmConfigVO.builder().engine(engine).model("qwen3.8-max").enabled(true).build(),
- engine, "hello", null, enhance, Duration.ofSeconds(300), null);
+ engine, "hello", resumeSessionId, enhance,
Duration.ofSeconds(300), null);
}
private List<String> types() {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiTimelineRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiTimelineRepositoryTest.java
index 8a82cd186..b305858e4 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiTimelineRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiTimelineRepositoryTest.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.ops.ai.conversation;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -49,6 +50,7 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -475,6 +477,31 @@ class AiTimelineRepositoryTest {
verify(conversationMapper, never()).selectObjs(any());
}
+ /**
+ * Forgetting the remembered {@code --resume} session is the recovery for
a stale one, so this write
+ * has to actually null the column. {@code updateById} skips null fields,
which is why the port has a
+ * method of its own — a fake pass through the ordinary update would be a
silent no-op that leaves the
+ * conversation pointing at a session that no longer exists.
+ */
+ @Test
+ void forgettingTheRuntimeSessionShouldNullTheColumnByNameTest() {
+ conversations.clearRuntimeSessionId(CONVERSATION_ID);
+
+ UpdateWrapper<RmqAiConversation> update = capturedConversationUpdate();
+ assertThat(update.getSqlSet()).contains("runtime_session_id");
+ // The bound value is what makes it a clear rather than a no-op.
+ assertThat(update.getParamNameValuePairs()).containsValue(null);
+ assertThat(update.getTargetSql()).contains("id = ?");
+
assertThat(update.getParamNameValuePairs().values()).contains(CONVERSATION_ID);
+ }
+
+ @Test
+ void forgettingTheRuntimeSessionShouldNeedAnIdTest() {
+ conversations.clearRuntimeSessionId(null);
+
+ verify(conversationMapper, never()).update(any(), any());
+ }
+
// --- the run rows
-----------------------------------------------------------------
/**
@@ -714,6 +741,13 @@ class AiTimelineRepositoryTest {
return queryOf(captor.getValue());
}
+ @SuppressWarnings("unchecked")
+ private UpdateWrapper<RmqAiConversation> capturedConversationUpdate() {
+ ArgumentCaptor<Wrapper<RmqAiConversation>> captor = wrapperCaptor();
+ verify(conversationMapper).update(isNull(), captor.capture());
+ return (UpdateWrapper<RmqAiConversation>) captor.getValue();
+ }
+
private QueryWrapper<RmqAiRun> capturedRunSelectList() {
ArgumentCaptor<Wrapper<RmqAiRun>> captor = wrapperCaptor();
verify(runMapper).selectList(captor.capture());