Repository: agentscope-ai/agentscope-java (2026-07-16T15:53:14Z)

Issue:
Core: user interrupt returns MODEL_STOP instead of GenerateReason.INTERRUPTED
## Summary
AgentScope v2 documentation and `GenerateReason` promise that a user interrupt returns `GenerateReason.INTERRUPTED`, but the current `ReActAgent.handleInterrupt(...)` returns a plain `AssistantMessage` without setting the generate reason. Consumers therefore observe the default `MODEL_STOP` and cannot distinguish a user interruption from normal model completion.

## Reproduction

With a published 2.0.x dependency and a session-scoped delayed model:

1. Start `agent.call(...)` for `(userId, sessionId)`.
2. Call `agent.interrupt(userId, sessionId, new UserMessage("stop"))` while the model stream is in flight.
3. Await the returned `Msg`.

Observed: `msg.getGenerateReason() == GenerateReason.MODEL_STOP`.
Expected: `GenerateReason.INTERRUPTED`.

The same behavior is covered by the downstream deterministic contract in JCode: `JCodeLongConversationFailureRecoveryContractTest#targetedInterruptPersistsRecoveryAndFreshRuntimeContinuesSameSession`.

## Source evidence

- `agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java`, `handleInterrupt(...)` builds and persists `recoveryMsg`, then returns it without `.withGenerateReason(GenerateReason.INTERRUPTED)`.
- `agentscope-core/src/main/java/io/agentscope/core/message/GenerateReason.java` defines `INTERRUPTED`.
- v2 docs `docs/building-blocks/agent.md` state that an interrupt returns a Msg tagged `GenerateReason.INTERRUPTED`.

## Proposed fix

Return the recovery message with `GenerateReason.INTERRUPTED` for user interrupts, while preserving the existing system-shutdown path and state persistence. Add a Core regression test covering a delayed in-flight model, targeted session isolation, persisted recovery, and the returned reason.

No raw credentials, response body, or product-host-specific behavior is involved. Product hosts should consume the typed Core reason rather than infer interruption from text or logs.


Discussion:

Patch:
diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
index 6778e170e2..c06b150a17 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
@@ -3550,6 +3550,7 @@ protected Mono<Msg> handleInterrupt(InterruptContext context, Msg... originalArg
                             AssistantMessage.builder()
                                     .name(getName())
                                     .content(TextBlock.builder().text(recoveryText).build())
+                                    .generateReason(GenerateReason.INTERRUPTED)
                                     .build();
                     scope.state.contextMutable().add(recoveryMsg);
                     return saveStateToSession(scope)


Test patch:
diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentPerSessionStateTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentPerSessionStateTest.java
index 5d88b1febc..f611b66f66 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentPerSessionStateTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentPerSessionStateTest.java
@@ -26,6 +26,7 @@
 import io.agentscope.core.event.AgentEvent;
 import io.agentscope.core.event.AgentStartEvent;
 import io.agentscope.core.message.ContentBlock;
+import io.agentscope.core.message.GenerateReason;
 import io.agentscope.core.message.Msg;
 import io.agentscope.core.message.MsgRole;
 import io.agentscope.core.message.TextBlock;
@@ -145,13 +146,24 @@ void userInterruptPersistsRecoveryState() throws Exception {
         assertEquals(
                 "I noticed that you have interrupted me. What can I do for you?",
                 reply.getTextContent());
+        assertEquals(GenerateReason.INTERRUPTED, reply.getGenerateReason());
 
         ReActAgent reborn = agent(store);
-        List<String> texts = allText(reborn.getAgentState("u1", "sessA"));
+        AgentState restoredState = reborn.getAgentState("u1", "sessA");
+        List<String> texts = allText(restoredState);
         assertTrue(texts.contains("hello"), "user input should remain in persisted session state");
         assertTrue(
                 texts.contains("I noticed that you have interrupted me. What can I do for you?"),
                 "interrupt recovery message should be persisted to the state store");
+        Msg restoredRecovery =
+                restoredState.getContext().stream()
+                        .filter(
+                                msg ->
+                                        "I noticed that you have interrupted me. What can I do for you?"
+                                                .equals(msg.getTextContent()))
+                        .findFirst()
+                        .orElseThrow();
+        assertEquals(GenerateReason.INTERRUPTED, restoredRecovery.getGenerateReason());
     }
 
     private static final class DelayedFirstChunkModel extends ChatModelBase {
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java
index 19e4b5551f..231d0ac5cb 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java
@@ -61,6 +61,7 @@ class HarnessAgentSubagentStreamEventsTest {
     @TempDir Path stateHome;
 
     private String previousStateHome;
+    private HarnessAgent parent;
 
     @BeforeEach
     void overrideStateHome() {
@@ -69,11 +70,17 @@ void overrideStateHome() {
     }
 
     @AfterEach
-    void restoreStateHome() {
-        if (previousStateHome != null) {
-            System.setProperty("agentscope.state.home", previousStateHome);
-        } else {
-            System.clearProperty("agentscope.state.home");
+    void tearDown() {
+        try {
+            if (parent != null) {
+                parent.close();
+            }
+        } finally {
+            if (previousStateHome != null) {
+                System.setProperty("agentscope.state.home", previousStateHome);
+            } else {
+                System.clearProperty("agentscope.state.home");
+            }
         }
     }
 
@@ -141,7 +148,7 @@ void streamEvents_childEventsForwardedWithSource() throws Exception {
                                 stopChunk("c1", "research complete")))
                 .thenReturn(Flux.just(stopChunk("p2", "summary done")));
 
-        HarnessAgent parent =
+        parent =
                 HarnessAgent.builder()
                         .name("parent")
                         .model(model)
@@ -214,7 +221,7 @@ void streamEvents_childAgentStartAndEndEmittedWithSource() throws Exception {
                 .thenReturn(Flux.just(stopChunk("c1", "formatted")))
                 .thenReturn(Flux.just(stopChunk("p2", "all done")));
 
-        HarnessAgent parent =
+        parent =
                 HarnessAgent.builder()
                         .name("parent")
                         .model(model)
@@ -281,7 +288,7 @@ void streamEvents_eventOrdering_childBetweenParentStartAndEnd() throws Exception
                 .thenReturn(Flux.just(stopChunk("c1", "analysis complete")))
                 .thenReturn(Flux.just(stopChunk("p2", "result obtained")));
 
-        HarnessAgent parent =
+        parent =
                 HarnessAgent.builder()
                         .name("parent")
                         .model(model)
@@ -347,7 +354,7 @@ void streamEvents_parentEventsHaveNullSource() throws Exception {
                 .thenReturn(Flux.just(stopChunk("c1", "helped")))
                 .thenReturn(Flux.just(stopChunk("p2", "done")));
 
-        HarnessAgent parent =
+        parent =
                 HarnessAgent.builder()
                         .name("parent")
                         .model(model)