From fe03dad31ea6007b0c5c98a1c323c4a0c56189a7 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:03:38 +0700 Subject: [PATCH 1/4] fix(core): support custom HITL deny reason and emit tool result events When the user denies an ASK confirmation, allow ConfirmResult.message to override the fixed "Permission denied by user" tool result, and emit the same ToolResultStart/TextDelta/End sequence that auto-deny already uses. Fixes #2492 --- .../java/io/agentscope/core/ReActAgent.java | 39 +++++- .../agentscope/core/event/ConfirmResult.java | 37 ++++- .../core/agent/ReActAgentHitlTest.java | 126 +++++++++++++++--- 3 files changed, 175 insertions(+), 27 deletions(-) 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 562f1f9ed6..33b42ceff6 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -1630,13 +1630,15 @@ private List extractConfirmResults(List msgs) { * modified) one from the result, set state to {@link ToolCallState#ALLOWED}, and * register any attached {@link PermissionRule}s with the engine. *
  • {@code confirmed == false}: write a DENIED {@link ToolResultBlock} to context so - * the tool will no longer be pending on resume.
  • + * the tool will no longer be pending on resume, and emit the same + * {@link ToolResultStartEvent} / {@link ToolResultTextDeltaEvent} / + * {@link ToolResultEndEvent} sequence used for auto-denied tools. * */ private void applyConfirmResults(List results) { // Replace ASKING ToolUseBlocks with possibly-modified ones from the user, and // promote them to ALLOWED. Collect denied ones for separate handling. - List deniedToolCalls = new ArrayList<>(); + List> deniedEntries = new ArrayList<>(); Map replacements = new HashMap<>(); Map stateUpdates = new HashMap<>(); for (ConfirmResult r : results) { @@ -1655,20 +1657,47 @@ private void applyConfirmResults(List results) { } } } else { - deniedToolCalls.add(target); + deniedEntries.add(Map.entry(target, resolveUserDenyMessage(r))); } } applyToolUseBlockReplacements(replacements); - for (ToolUseBlock denied : deniedToolCalls) { + if (deniedEntries.isEmpty()) { + return; + } + // Correlate all deny tool-result events for this resume with one reply id, matching + // the auto-deny path in runToolBatch. + String replyId = UUID.randomUUID().toString().replace("-", ""); + for (Map.Entry entry : deniedEntries) { + ToolUseBlock denied = entry.getKey(); + String denyMessage = entry.getValue(); ToolResultBlock deniedResult = - ToolResultBlock.text("Permission denied by user") + ToolResultBlock.text(denyMessage) .withIdAndName(denied.getId(), denied.getName()) .withState(ToolResultState.DENIED); Msg deniedMsg = ToolResultMessageBuilder.buildToolResultMsg( deniedResult, denied, getName()); state.contextMutable().add(deniedMsg); + publishEvent(new ToolResultStartEvent(replyId, denied.getId(), denied.getName())); + publishEvent( + new ToolResultTextDeltaEvent( + replyId, denied.getId(), denied.getName(), denyMessage)); + publishEvent( + new ToolResultEndEvent( + replyId, denied.getId(), denied.getName(), ToolResultState.DENIED)); + } + } + + /** + * Resolve the tool-result text for a user deny. Prefer {@link ConfirmResult#getMessage()} + * when present; otherwise keep the historical default. + */ + private static String resolveUserDenyMessage(ConfirmResult result) { + String message = result.getMessage(); + if (message != null && !message.isBlank()) { + return message; } + return "Permission denied by user"; } /** diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ConfirmResult.java b/agentscope-core/src/main/java/io/agentscope/core/event/ConfirmResult.java index 1d194266fd..7c7a58ac6d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ConfirmResult.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ConfirmResult.java @@ -27,26 +27,46 @@ *

    When confirmed, the caller may supply a modified {@link #toolCall} (allowing the user to * tweak input) and/or new {@link #rules} that the {@code PermissionEngine} should remember for * future calls — e.g. "always allow this command going forward". + * + *

    When denied ({@code confirmed == false}), an optional {@link #message} can override the + * default tool-result text ({@code "Permission denied by user"}). */ public class ConfirmResult { private final boolean confirmed; private final ToolUseBlock toolCall; private final List rules; + private final String message; @JsonCreator public ConfirmResult( @JsonProperty("confirmed") boolean confirmed, @JsonProperty("toolCall") ToolUseBlock toolCall, - @JsonProperty("rules") List rules) { + @JsonProperty("rules") List rules, + @JsonProperty("message") String message) { this.confirmed = confirmed; this.toolCall = toolCall; this.rules = rules; + this.message = message; + } + + /** Convenience constructor without a custom deny message. */ + public ConfirmResult(boolean confirmed, ToolUseBlock toolCall, List rules) { + this(confirmed, toolCall, rules, null); } - /** Convenience constructor without rules. */ + /** Convenience constructor without rules or a custom deny message. */ public ConfirmResult(boolean confirmed, ToolUseBlock toolCall) { - this(confirmed, toolCall, null); + this(confirmed, toolCall, null, null); + } + + /** + * Convenience constructor with a custom deny message and no rules. + * + *

    Useful when the user rejects a tool call and wants the model to see a specific reason. + */ + public ConfirmResult(boolean confirmed, ToolUseBlock toolCall, String message) { + this(confirmed, toolCall, null, message); } public boolean isConfirmed() { @@ -66,4 +86,15 @@ public ToolUseBlock getToolCall() { public List getRules() { return rules; } + + /** + * Optional text used as the denied tool-result content when {@link #confirmed} is false. + * + *

    When null or blank, the agent falls back to {@code "Permission denied by user"}. + * + * @return custom deny message, or null + */ + public String getMessage() { + return message; + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java index 580317a591..2689fefdd0 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java @@ -26,6 +26,8 @@ import io.agentscope.core.event.RequestStopEvent; import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.ToolResultEndEvent; +import io.agentscope.core.event.ToolResultStartEvent; +import io.agentscope.core.event.ToolResultTextDeltaEvent; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.GenerateReason; import io.agentscope.core.message.Msg; @@ -200,10 +202,12 @@ private static int countOf(List events, Class type) { } private static Msg confirmMsg(boolean confirmed, ToolUseBlock toolCall) { + return confirmMsg(new ConfirmResult(confirmed, toolCall)); + } + + private static Msg confirmMsg(ConfirmResult result) { Map meta = new HashMap<>(); - meta.put( - Msg.METADATA_CONFIRM_RESULTS, - List.of(new ConfirmResult(confirmed, toolCall, null))); + meta.put(Msg.METADATA_CONFIRM_RESULTS, List.of(result)); return Msg.builder() .name("user") .role(MsgRole.USER) @@ -212,6 +216,23 @@ private static Msg confirmMsg(boolean confirmed, ToolUseBlock toolCall) { .build(); } + private static ToolUseBlock pendingAskingTool(ReActAgent agent) { + for (int i = agent.getAgentState().getContext().size() - 1; i >= 0; i--) { + Msg m = agent.getAgentState().getContext().get(i); + if (m.getRole() == MsgRole.ASSISTANT) { + return m.getContentBlocks(ToolUseBlock.class).get(0); + } + } + throw new AssertionError("expected an assistant message with a pending tool call"); + } + + private static String toolResultText(ToolResultBlock block) { + return block.getOutput().stream() + .filter(b -> b instanceof TextBlock) + .map(b -> ((TextBlock) b).getText()) + .reduce("", String::concat); + } + @Test void askingToolPausesFirstCallAndExecutesOnConfirmedSecondCall() { ChatModelBase model = @@ -295,29 +316,96 @@ void askingToolResumeWithDeniedConfirmResultProducesDeniedToolResult() { assertNotNull(first); assertEquals(GenerateReason.PERMISSION_ASKING, first.getGenerateReason()); - Msg lastAssistant = null; - for (int i = agent.getAgentState().getContext().size() - 1; i >= 0; i--) { - Msg m = agent.getAgentState().getContext().get(i); - if (m.getRole() == MsgRole.ASSISTANT) { - lastAssistant = m; - break; - } - } - ToolUseBlock pending = lastAssistant.getContentBlocks(ToolUseBlock.class).get(0); + ToolUseBlock pending = pendingAskingTool(agent); // Second call → deny Msg second = agent.call(List.of(confirmMsg(false, pending))).block(); assertNotNull(second); - // Context should contain a DENIED ToolResultBlock for tc1 - boolean foundDenied = + // Context should contain a DENIED ToolResultBlock for tc1 with the default message + ToolResultBlock denied = agent.getAgentState().getContext().stream() .flatMap(m -> m.getContentBlocks(ToolResultBlock.class).stream()) - .anyMatch( - tr -> - "tc1".equals(tr.getId()) - && tr.getState() == ToolResultState.DENIED); - assertTrue(foundDenied, "expected a DENIED ToolResultBlock for the rejected tool"); + .filter(tr -> "tc1".equals(tr.getId())) + .findFirst() + .orElse(null); + assertNotNull(denied, "expected a DENIED ToolResultBlock for the rejected tool"); + assertEquals(ToolResultState.DENIED, denied.getState()); + assertEquals("Permission denied by user", toolResultText(denied)); + } + + @Test + void askingToolResumeWithCustomDenyMessageUsesConfirmResultMessage() { + ChatModelBase model = + new ScriptedModel( + List.of( + () -> Flux.just(toolUseResponse("tc1", "ask", "x")), + () -> Flux.just(textResponse("done")))); + ReActAgent agent = buildAgent(model, toolkitWith(new AskingTool("ask"))); + + Msg first = agent.call(List.of()).block(); + assertNotNull(first); + assertEquals(GenerateReason.PERMISSION_ASKING, first.getGenerateReason()); + + ToolUseBlock pending = pendingAskingTool(agent); + String reason = "User rejected: do not delete production data"; + Msg second = + agent.call(List.of(confirmMsg(new ConfirmResult(false, pending, reason)))).block(); + assertNotNull(second); + + ToolResultBlock denied = + agent.getAgentState().getContext().stream() + .flatMap(m -> m.getContentBlocks(ToolResultBlock.class).stream()) + .filter(tr -> "tc1".equals(tr.getId())) + .findFirst() + .orElse(null); + assertNotNull(denied); + assertEquals(ToolResultState.DENIED, denied.getState()); + assertEquals(reason, toolResultText(denied)); + } + + @Test + void askingToolResumeWithDenyEmitsToolResultEvents() { + ChatModelBase model = + new ScriptedModel( + List.of( + () -> Flux.just(toolUseResponse("tc1", "ask", "x")), + () -> Flux.just(textResponse("done")))); + ReActAgent agent = buildAgent(model, toolkitWith(new AskingTool("ask"))); + + // First stream: pause on ASK + List firstEvents = agent.streamEvents(List.of()).collectList().block(); + assertNotNull(firstEvents); + assertTrue(indexOf(firstEvents, RequireUserConfirmEvent.class) >= 0); + + ToolUseBlock pending = pendingAskingTool(agent); + String reason = "blocked by policy"; + + // Second stream: deny — should surface the same Start/Delta/End sequence as auto-deny + List resumeEvents = + agent.streamEvents(List.of(confirmMsg(new ConfirmResult(false, pending, reason)))) + .collectList() + .block(); + assertNotNull(resumeEvents); + + int iStart = indexOf(resumeEvents, ToolResultStartEvent.class); + int iDelta = indexOf(resumeEvents, ToolResultTextDeltaEvent.class); + int iEnd = indexOf(resumeEvents, ToolResultEndEvent.class); + assertTrue(iStart >= 0, "ToolResultStartEvent must be emitted on manual deny"); + assertTrue(iDelta > iStart, "ToolResultTextDeltaEvent must follow Start"); + assertTrue(iEnd > iDelta, "ToolResultEndEvent must follow TextDelta"); + + ToolResultStartEvent start = (ToolResultStartEvent) resumeEvents.get(iStart); + assertEquals("tc1", start.getToolCallId()); + assertEquals("ask", start.getToolCallName()); + + ToolResultTextDeltaEvent delta = (ToolResultTextDeltaEvent) resumeEvents.get(iDelta); + assertEquals("tc1", delta.getToolCallId()); + assertEquals(reason, delta.getDelta()); + + ToolResultEndEvent end = (ToolResultEndEvent) resumeEvents.get(iEnd); + assertEquals("tc1", end.getToolCallId()); + assertEquals(ToolResultState.DENIED, end.getState()); } @Test From 5972db2444ccab734e47b134bd2e6c61ac116eff Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:34:27 +0700 Subject: [PATCH 2/4] fix(core): disambiguate ConfirmResult deny-message API Drop the 3-arg (confirmed, toolCall, message) constructor that collided with (confirmed, toolCall, rules) when null was passed. Use ConfirmResult.withMessage(...) or the 4-arg constructor instead. Add HITL test that blank/whitespace deny message falls back to "Permission denied by user", and note the API in permission docs. --- .../agentscope/core/event/ConfirmResult.java | 19 +++++++--- .../core/agent/ReActAgentHitlTest.java | 38 ++++++++++++++++++- .../docs/building-blocks/permission-system.md | 5 ++- .../docs/building-blocks/permission-system.md | 5 ++- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ConfirmResult.java b/agentscope-core/src/main/java/io/agentscope/core/event/ConfirmResult.java index 7c7a58ac6d..2668d4a0cb 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ConfirmResult.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ConfirmResult.java @@ -29,7 +29,9 @@ * future calls — e.g. "always allow this command going forward". * *

    When denied ({@code confirmed == false}), an optional {@link #message} can override the - * default tool-result text ({@code "Permission denied by user"}). + * default tool-result text ({@code "Permission denied by user"}). Prefer {@link #withMessage} or + * the 4-arg constructor — there is no 3-arg {@code (confirmed, toolCall, message)} overload so + * {@code new ConfirmResult(c, t, null)} stays unambiguous against the rules overload. */ public class ConfirmResult { @@ -61,12 +63,19 @@ public ConfirmResult(boolean confirmed, ToolUseBlock toolCall) { } /** - * Convenience constructor with a custom deny message and no rules. + * Factory for a confirm result with a custom deny message and no rules. * - *

    Useful when the user rejects a tool call and wants the model to see a specific reason. + *

    Use this (or the 4-arg constructor) instead of a 3-arg message overload so that {@code + * new ConfirmResult(confirmed, toolCall, null)} remains unambiguously the rules constructor. + * + * @param confirmed whether the user approved the tool call + * @param toolCall the (possibly modified) tool call being decided + * @param message custom tool-result text when denying; blank/null falls back to the default + * @return a new {@link ConfirmResult} */ - public ConfirmResult(boolean confirmed, ToolUseBlock toolCall, String message) { - this(confirmed, toolCall, null, message); + public static ConfirmResult withMessage( + boolean confirmed, ToolUseBlock toolCall, String message) { + return new ConfirmResult(confirmed, toolCall, null, message); } public boolean isConfirmed() { diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java index 2689fefdd0..2be690eb0e 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java @@ -350,7 +350,8 @@ void askingToolResumeWithCustomDenyMessageUsesConfirmResultMessage() { ToolUseBlock pending = pendingAskingTool(agent); String reason = "User rejected: do not delete production data"; Msg second = - agent.call(List.of(confirmMsg(new ConfirmResult(false, pending, reason)))).block(); + agent.call(List.of(confirmMsg(ConfirmResult.withMessage(false, pending, reason)))) + .block(); assertNotNull(second); ToolResultBlock denied = @@ -364,6 +365,36 @@ void askingToolResumeWithCustomDenyMessageUsesConfirmResultMessage() { assertEquals(reason, toolResultText(denied)); } + @Test + void askingToolResumeWithBlankDenyMessageFallsBackToDefault() { + ChatModelBase model = + new ScriptedModel( + List.of( + () -> Flux.just(toolUseResponse("tc1", "ask", "x")), + () -> Flux.just(textResponse("done")))); + ReActAgent agent = buildAgent(model, toolkitWith(new AskingTool("ask"))); + + Msg first = agent.call(List.of()).block(); + assertNotNull(first); + assertEquals(GenerateReason.PERMISSION_ASKING, first.getGenerateReason()); + + ToolUseBlock pending = pendingAskingTool(agent); + Msg second = + agent.call(List.of(confirmMsg(ConfirmResult.withMessage(false, pending, " ")))) + .block(); + assertNotNull(second); + + ToolResultBlock denied = + agent.getAgentState().getContext().stream() + .flatMap(m -> m.getContentBlocks(ToolResultBlock.class).stream()) + .filter(tr -> "tc1".equals(tr.getId())) + .findFirst() + .orElse(null); + assertNotNull(denied); + assertEquals(ToolResultState.DENIED, denied.getState()); + assertEquals("Permission denied by user", toolResultText(denied)); + } + @Test void askingToolResumeWithDenyEmitsToolResultEvents() { ChatModelBase model = @@ -383,7 +414,10 @@ void askingToolResumeWithDenyEmitsToolResultEvents() { // Second stream: deny — should surface the same Start/Delta/End sequence as auto-deny List resumeEvents = - agent.streamEvents(List.of(confirmMsg(new ConfirmResult(false, pending, reason)))) + agent.streamEvents( + List.of( + confirmMsg( + ConfirmResult.withMessage(false, pending, reason)))) .collectList() .block(); assertNotNull(resumeEvents); diff --git a/docs/v2/en/docs/building-blocks/permission-system.md b/docs/v2/en/docs/building-blocks/permission-system.md index 2825c0df84..561f3c5cd6 100644 --- a/docs/v2/en/docs/building-blocks/permission-system.md +++ b/docs/v2/en/docs/building-blocks/permission-system.md @@ -314,7 +314,10 @@ if (result != null && result.getGenerateReason() == GenerateReason.PERMISSION_AS // Show pending operations to the user askingTools.forEach(t -> System.out.println("Pending: " + t.getName() + " " + t.getInput())); - // 4. Collect the user's decision, build ConfirmResult, and resume + // 4. Collect the user's decision, build ConfirmResult, and resume. + // Deny with a custom tool-result reason via ConfirmResult.withMessage(...), or the + // 4-arg constructor (confirmed, toolCall, rules, message). Blank/null message keeps + // the default "Permission denied by user". boolean approved = askUser(); List confirmResults = askingTools.stream() diff --git a/docs/v2/zh/docs/building-blocks/permission-system.md b/docs/v2/zh/docs/building-blocks/permission-system.md index b02efafb3b..6f63366516 100644 --- a/docs/v2/zh/docs/building-blocks/permission-system.md +++ b/docs/v2/zh/docs/building-blocks/permission-system.md @@ -314,7 +314,10 @@ if (result != null && result.getGenerateReason() == GenerateReason.PERMISSION_AS // 向用户展示 askingTools.forEach(t -> System.out.println("Pending: " + t.getName() + " " + t.getInput())); - // 4. 收集用户决策,构建 ConfirmResult 恢复 agent + // 4. 收集用户决策,构建 ConfirmResult 恢复 agent。 + // 拒绝时可自定义 tool-result 文案:ConfirmResult.withMessage(...) 或 4 参构造 + // (confirmed, toolCall, rules, message)。message 为 null/空白时回退为 + // "Permission denied by user"。 boolean approved = askUser(); List confirmResults = askingTools.stream() From d0ab9be2c76462dde0a2c3b3010970533e0e2ba3 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:44:02 +0700 Subject: [PATCH 3/4] ci: re-trigger after harness stream flake HarnessAgentSubagentStreamEventsTest.streamEvents_childAgentStartAndEndEmittedWithSource failed once on CI (no child AGENT_END with source) after an unrelated ConfirmResult API commit. Full harness suite (699 tests) is green locally; prior tip fe03dad3 was green on CI. From 87502d5e3cb7da6751e416ebd095fbe5d1323e58 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:13:38 +0700 Subject: [PATCH 4/4] fix(harness): emit child AGENT_END before tool mono completes execWithTimeoutPromotion bridges execLocalSync via CompletableFuture and whenComplete, which can race doFinally: the parent streamEvents sink may complete before the child AGENT_END is emitted, so CI flaked on HarnessAgentSubagentStreamEventsTest (empty child AGENT_END with source). Emit the bookend in flatMap (before the value reaches the bridge) and keep doFinally only for CANCEL, with AtomicBoolean dedupe. --- .../harness/agent/tool/AgentSpawnTool.java | 35 +++++++++++++++---- .../HarnessAgentSubagentStreamEventsTest.java | 7 +++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java index 8b3ca4b17c..96f2740698 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java @@ -56,6 +56,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.Disposable; @@ -673,20 +674,40 @@ private Mono execLocalSync( new AgentStartEvent(spawned.sessionId(), null, spawned.agentId()) .withSource(sourcePath)); + // Emit AGENT_END at most once. Prefer the success path (flatMap) so the + // bookend is delivered *before* the Mono value reaches + // execWithTimeoutPromotion's CompletableFuture bridge — otherwise + // whenComplete can advance the parent and complete the streamEvents sink + // before doFinally runs, dropping the child AGENT_END under load (CI). + AtomicBoolean endEmitted = new AtomicBoolean(false); + Runnable emitChildEnd = + () -> { + if (endEmitted.compareAndSet(false, true)) { + parentEmitter.emit( + new AgentEndEvent(null).withSource(sourcePath)); + } + }; + return manager.invokeAgent(agent, sessionId, userId, prompt, parentCtx) .contextWrite( c -> c.put( AgentEventEmitter.FORWARDING_CONTEXT_KEY, taggedEmitter)) - // doFinally, not doOnTerminate: the latter skips cancel, so a - // parent cancel would leave the AgentStartEvent above unmatched - // and consumers would render this subagent as running forever. + .flatMap( + msg -> { + emitChildEnd.run(); + return Mono.just(msg); + }) + .doOnError(err -> emitChildEnd.run()) + // Cancel: flatMap/doOnError do not run; still need the bookend so + // consumers do not render the subagent as running forever. .doFinally( - signal -> - parentEmitter.emit( - new AgentEndEvent(null) - .withSource(sourcePath))); + signal -> { + if (signal == SignalType.CANCEL) { + emitChildEnd.run(); + } + }); } // ── Path 2: stream() (deprecated) — SubagentEventBus forwarding ── 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 231d0ac5cb..bde29aa041 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 @@ -257,7 +257,12 @@ void streamEvents_childAgentStartAndEndEmittedWithSource() throws Exception { e.getType() == AgentEventType.AGENT_END && e.getSource() != null) .collect(Collectors.toList()); - assertFalse(childEnds.isEmpty(), "expected child AGENT_END with source"); + assertFalse( + childEnds.isEmpty(), + "expected child AGENT_END with source; events=" + + events.stream() + .map(e -> e.getType() + "(src=" + e.getSource() + ")") + .collect(Collectors.joining(", "))); assertTrue(childEnds.get(0).getSource().contains(childId)); }