From 5a5718110595ac09a85a5df519899c6fc005f4c3 Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:20:58 +0800 Subject: [PATCH 01/22] =?UTF-8?q?feat(core):=20=E6=96=B0=E5=A2=9E=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E8=BE=93=E5=87=BA=E5=A4=84=E7=BD=AE=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 0db3ed7bd2d533ce0ce2102291e9ee0c28bc8244) --- .../io/agentscope/core/event/AgentEvent.java | 1 + .../agentscope/core/event/AgentEventType.java | 1 + .../core/event/TextOutputDisposition.java | 22 ++++++ .../event/TextOutputDispositionEvent.java | 71 +++++++++++++++++++ .../event/TextOutputDispositionEventTest.java | 57 +++++++++++++++ 5 files changed, 152 insertions(+) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDisposition.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/event/TextOutputDispositionEventTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java index 041dab783a..c8da5c9534 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java @@ -37,6 +37,7 @@ @JsonSubTypes.Type(value = AgentStartEvent.class, name = "AGENT_START"), @JsonSubTypes.Type(value = AgentEndEvent.class, name = "AGENT_END"), @JsonSubTypes.Type(value = AgentResultEvent.class, name = "AGENT_RESULT"), + @JsonSubTypes.Type(value = TextOutputDispositionEvent.class, name = "TEXT_OUTPUT_DISPOSITION"), @JsonSubTypes.Type(value = ModelCallStartEvent.class, name = "MODEL_CALL_START"), @JsonSubTypes.Type(value = ModelCallEndEvent.class, name = "MODEL_CALL_END"), @JsonSubTypes.Type(value = TextBlockStartEvent.class, name = "TEXT_BLOCK_START"), diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java index 69cd2f046f..ce51a1483d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java @@ -43,6 +43,7 @@ public enum AgentEventType { @JsonAlias({"RUN_FINISHED", "REPLY_END"}) AGENT_END("AGENT_END"), AGENT_RESULT("AGENT_RESULT"), + TEXT_OUTPUT_DISPOSITION("TEXT_OUTPUT_DISPOSITION"), @JsonAlias({"MODEL_CALL_STARTED"}) MODEL_CALL_START("MODEL_CALL_START"), diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDisposition.java b/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDisposition.java new file mode 100644 index 0000000000..23430ddcee --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDisposition.java @@ -0,0 +1,22 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.event; + +/** Describes whether a streamed model reply is intermediate or terminal for an invocation. */ +public enum TextOutputDisposition { + INTERMEDIATE, + TERMINAL +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java new file mode 100644 index 0000000000..db2cab5812 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java @@ -0,0 +1,71 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.event; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.agentscope.core.message.GenerateReason; +import java.util.Objects; + +/** + * Classifies an already-streamed model reply as intermediate or terminal for its agent invocation. + * + *

A terminal disposition is a lifecycle signal, not an authoritative final answer. Consumers + * must use {@link AgentResultEvent} for the invocation result. + */ +public final class TextOutputDispositionEvent extends AgentEvent { + + private final String replyId; + private final TextOutputDisposition disposition; + private final GenerateReason generateReason; + + public TextOutputDispositionEvent( + String replyId, TextOutputDisposition disposition, GenerateReason generateReason) { + this.replyId = replyId; + this.disposition = Objects.requireNonNull(disposition, "disposition"); + this.generateReason = generateReason; + } + + @JsonCreator + public TextOutputDispositionEvent( + @JsonProperty("id") String id, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("replyId") String replyId, + @JsonProperty("disposition") TextOutputDisposition disposition, + @JsonProperty("generateReason") GenerateReason generateReason) { + super(id, createdAt); + this.replyId = replyId; + this.disposition = Objects.requireNonNull(disposition, "disposition"); + this.generateReason = generateReason; + } + + @Override + public AgentEventType getType() { + return AgentEventType.TEXT_OUTPUT_DISPOSITION; + } + + public String getReplyId() { + return replyId; + } + + public TextOutputDisposition getDisposition() { + return disposition; + } + + public GenerateReason getGenerateReason() { + return generateReason; + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/TextOutputDispositionEventTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/TextOutputDispositionEventTest.java new file mode 100644 index 0000000000..2e703241f4 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/event/TextOutputDispositionEventTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.event; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.util.JsonUtils; +import org.junit.jupiter.api.Test; + +class TextOutputDispositionEventTest { + + @Test + void jsonRoundTripPreservesDispositionAndInheritedContext() { + TextOutputDispositionEvent original = + new TextOutputDispositionEvent( + "reply-1", TextOutputDisposition.TERMINAL, GenerateReason.MODEL_STOP); + original.withSource("parent/researcher") + .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-1"); + + String json = JsonUtils.getJsonCodec().toJson(original); + AgentEvent decoded = JsonUtils.getJsonCodec().fromJson(json, AgentEvent.class); + + TextOutputDispositionEvent restored = + assertInstanceOf(TextOutputDispositionEvent.class, decoded); + assertEquals(AgentEventType.TEXT_OUTPUT_DISPOSITION, restored.getType()); + assertEquals("reply-1", restored.getReplyId()); + assertEquals(TextOutputDisposition.TERMINAL, restored.getDisposition()); + assertEquals(GenerateReason.MODEL_STOP, restored.getGenerateReason()); + assertEquals("parent/researcher", restored.getSource()); + assertEquals("task-1", restored.getMetadata().get(AgentEvent.METADATA_TASK_ID)); + } + + @Test + void intermediateDispositionDoesNotClaimGenerateReason() { + TextOutputDispositionEvent event = + new TextOutputDispositionEvent("reply-2", TextOutputDisposition.INTERMEDIATE, null); + + assertEquals(TextOutputDisposition.INTERMEDIATE, event.getDisposition()); + assertNull(event.getGenerateReason()); + } +} From 8a45285e6bf9f129103ac7f478c7dc8b56a1cc90 Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:20:58 +0800 Subject: [PATCH 02/22] =?UTF-8?q?refactor(core):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E8=B7=9F?= =?UTF-8?q?=E8=B8=AA=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 72e56f52c3124b03988c1f08894ca8651d7a0ab0) --- .../stream/ReplyLifecycleTracker.java | 230 ++++++++++++++++++ .../FinalAnswerFilterMiddleware.java | 48 +--- .../stream/ReplyLifecycleTrackerTest.java | 94 +++++++ 3 files changed, 336 insertions(+), 36 deletions(-) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java new file mode 100644 index 0000000000..4c32500b7e --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java @@ -0,0 +1,230 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.internal.stream; + +import io.agentscope.core.event.AgentEndEvent; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentResultEvent; +import io.agentscope.core.event.ModelCallEndEvent; +import io.agentscope.core.event.ModelCallStartEvent; +import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.TextBlockEndEvent; +import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.ToolCallStartEvent; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Internal state tracker shared by stream annotators and middleware that reason about model replies. + * + *

This type is public only so internal components in different packages can share one set of + * reply/source correlation rules. It is not a stable public API. + */ +public final class ReplyLifecycleTracker { + + public enum EventKind { + MODEL_CALL_START, + MODEL_CALL_END, + TEXT_BLOCK_START, + TEXT_BLOCK_DELTA, + TEXT_BLOCK_END, + TOOL_CALL_START, + AGENT_RESULT, + AGENT_END, + OTHER + } + + public record SourceKey(String source, String taskId) { + + public SourceKey { + source = source == null ? "" : source; + taskId = taskId == null ? "" : taskId; + } + + public static SourceKey topLevel() { + return new SourceKey("", ""); + } + + public boolean isTopLevel() { + return source.isEmpty(); + } + } + + public record ReplySnapshot( + String replyId, + boolean textSeen, + boolean toolCallSeen, + boolean dispositionEmitted, + AgentResultEvent lastResult) {} + + public record Observation( + SourceKey sourceKey, + EventKind kind, + String eventReplyId, + boolean currentReplyEvent, + ReplySnapshot before, + ReplySnapshot after) {} + + private final Map states = new LinkedHashMap<>(); + + public SourceKey sourceKey(AgentEvent event) { + Objects.requireNonNull(event, "event"); + Object taskId = + event.getMetadata() == null + ? null + : event.getMetadata().get(AgentEvent.METADATA_TASK_ID); + return new SourceKey(event.getSource(), taskId == null ? null : taskId.toString()); + } + + public Observation observe(AgentEvent event) { + Objects.requireNonNull(event, "event"); + SourceKey sourceKey = sourceKey(event); + ReplyState state = states.computeIfAbsent(sourceKey, ignored -> new ReplyState()); + ReplySnapshot before = state.snapshot(); + EventKind kind = eventKind(event); + String eventReplyId = replyId(event); + boolean currentReplyEvent = + eventReplyId != null && Objects.equals(state.replyId, eventReplyId); + + switch (kind) { + case MODEL_CALL_START -> { + state.replyId = eventReplyId; + state.textSeen = false; + state.toolCallSeen = false; + state.dispositionEmitted = false; + currentReplyEvent = true; + } + case TEXT_BLOCK_DELTA -> { + if (currentReplyEvent + && event instanceof TextBlockDeltaEvent delta + && delta.getDelta() != null + && !delta.getDelta().isEmpty()) { + state.textSeen = true; + } + } + case TOOL_CALL_START -> { + if (currentReplyEvent) { + state.toolCallSeen = true; + } + } + case AGENT_RESULT -> state.lastResult = (AgentResultEvent) event; + default -> { + // The remaining event kinds do not mutate shared reply state. + } + } + + return new Observation( + sourceKey, kind, eventReplyId, currentReplyEvent, before, state.snapshot()); + } + + public ReplySnapshot snapshot(SourceKey sourceKey) { + ReplyState state = states.get(sourceKey); + return state == null ? ReplyState.emptySnapshot() : state.snapshot(); + } + + public void markDispositionEmitted(SourceKey sourceKey) { + states.computeIfAbsent(sourceKey, ignored -> new ReplyState()).dispositionEmitted = true; + } + + public void clearReply(SourceKey sourceKey) { + ReplyState state = states.get(sourceKey); + if (state != null) { + state.replyId = null; + state.textSeen = false; + state.toolCallSeen = false; + state.dispositionEmitted = false; + } + } + + public void clearSource(SourceKey sourceKey) { + states.remove(sourceKey); + } + + public void clear() { + states.clear(); + } + + private static EventKind eventKind(AgentEvent event) { + if (event instanceof ModelCallStartEvent) { + return EventKind.MODEL_CALL_START; + } + if (event instanceof ModelCallEndEvent) { + return EventKind.MODEL_CALL_END; + } + if (event instanceof TextBlockStartEvent) { + return EventKind.TEXT_BLOCK_START; + } + if (event instanceof TextBlockDeltaEvent) { + return EventKind.TEXT_BLOCK_DELTA; + } + if (event instanceof TextBlockEndEvent) { + return EventKind.TEXT_BLOCK_END; + } + if (event instanceof ToolCallStartEvent) { + return EventKind.TOOL_CALL_START; + } + if (event instanceof AgentResultEvent) { + return EventKind.AGENT_RESULT; + } + if (event instanceof AgentEndEvent) { + return EventKind.AGENT_END; + } + return EventKind.OTHER; + } + + private static String replyId(AgentEvent event) { + if (event instanceof ModelCallStartEvent modelStart) { + return modelStart.getReplyId(); + } + if (event instanceof ModelCallEndEvent modelEnd) { + return modelEnd.getReplyId(); + } + if (event instanceof TextBlockStartEvent textStart) { + return textStart.getReplyId(); + } + if (event instanceof TextBlockDeltaEvent textDelta) { + return textDelta.getReplyId(); + } + if (event instanceof TextBlockEndEvent textEnd) { + return textEnd.getReplyId(); + } + if (event instanceof ToolCallStartEvent toolStart) { + return toolStart.getReplyId(); + } + if (event instanceof AgentEndEvent agentEnd) { + return agentEnd.getReplyId(); + } + return null; + } + + private static final class ReplyState { + private String replyId; + private boolean textSeen; + private boolean toolCallSeen; + private boolean dispositionEmitted; + private AgentResultEvent lastResult; + + private ReplySnapshot snapshot() { + return new ReplySnapshot( + replyId, textSeen, toolCallSeen, dispositionEmitted, lastResult); + } + + private static ReplySnapshot emptySnapshot() { + return new ReplySnapshot(null, false, false, false, null); + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/middleware/FinalAnswerFilterMiddleware.java b/agentscope-core/src/main/java/io/agentscope/core/middleware/FinalAnswerFilterMiddleware.java index 422bb3df8a..a0b367973c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/middleware/FinalAnswerFilterMiddleware.java +++ b/agentscope-core/src/main/java/io/agentscope/core/middleware/FinalAnswerFilterMiddleware.java @@ -24,9 +24,10 @@ import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; import io.agentscope.core.event.ToolCallStartEvent; +import io.agentscope.core.internal.stream.ReplyLifecycleTracker; +import io.agentscope.core.internal.stream.ReplyLifecycleTracker.Observation; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.function.Function; import reactor.core.publisher.Flux; @@ -66,37 +67,34 @@ private static boolean isTextBlockEvent(AgentEvent event) { private static final class RoundState { private final List bufferedTextEvents = new ArrayList<>(); - private String replyId; - private boolean toolCallSeen; + private final ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); private Flux handle(AgentEvent event) { - if (event instanceof ModelCallStartEvent start) { - replyId = start.getReplyId(); - toolCallSeen = false; + Observation observation = tracker.observe(event); + + if (event instanceof ModelCallStartEvent) { bufferedTextEvents.clear(); return Flux.just(event); } if (isTextBlockEvent(event)) { - if (isCurrentReply(event) && !toolCallSeen) { + if (observation.currentReplyEvent() && !observation.after().toolCallSeen()) { bufferedTextEvents.add(event); return Flux.empty(); } - if (isCurrentReply(event)) { + if (observation.currentReplyEvent()) { return Flux.empty(); } return Flux.just(event); } - if (event instanceof ToolCallStartEvent toolCall - && Objects.equals(replyId, toolCall.getReplyId())) { - toolCallSeen = true; + if (event instanceof ToolCallStartEvent && observation.currentReplyEvent()) { bufferedTextEvents.clear(); return Flux.just(event); } - if (event instanceof ModelCallEndEvent end && isCurrentReply(end)) { - if (toolCallSeen) { + if (event instanceof ModelCallEndEvent && observation.currentReplyEvent()) { + if (observation.after().toolCallSeen()) { clear(); return Flux.just(event); } @@ -110,31 +108,9 @@ private Flux handle(AgentEvent event) { return Flux.just(event); } - private boolean isCurrentReply(AgentEvent event) { - String eventReplyId = getReplyId(event); - return replyId != null && Objects.equals(replyId, eventReplyId); - } - - private static String getReplyId(AgentEvent event) { - if (event instanceof TextBlockStartEvent textStart) { - return textStart.getReplyId(); - } - if (event instanceof TextBlockDeltaEvent textDelta) { - return textDelta.getReplyId(); - } - if (event instanceof TextBlockEndEvent textEnd) { - return textEnd.getReplyId(); - } - if (event instanceof ModelCallEndEvent modelEnd) { - return modelEnd.getReplyId(); - } - return null; - } - private void clear() { bufferedTextEvents.clear(); - replyId = null; - toolCallSeen = false; + tracker.clear(); } } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java new file mode 100644 index 0000000000..6db629956c --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.internal.stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ModelCallStartEvent; +import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.ToolCallStartEvent; +import org.junit.jupiter.api.Test; + +class ReplyLifecycleTrackerTest { + + @Test + void sourceKeyUsesTaskIdToIsolateConcurrentCallsFromSameSource() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + AgentEvent taskA = + new ModelCallStartEvent("reply-a") + .withSource("parent/worker") + .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-a"); + AgentEvent taskB = + new ModelCallStartEvent("reply-b") + .withSource("parent/worker") + .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-b"); + + assertNotEquals(tracker.sourceKey(taskA), tracker.sourceKey(taskB)); + } + + @Test + void nonEmptyTextDeltaMarksOnlyItsCurrentReplyAsVisible() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + tracker.observe(new ModelCallStartEvent("reply-1")); + + ReplyLifecycleTracker.Observation blank = + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "")); + ReplyLifecycleTracker.Observation visible = + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "answer")); + + assertFalse(blank.after().textSeen()); + assertTrue(visible.currentReplyEvent()); + assertTrue(visible.after().textSeen()); + } + + @Test + void toolCallForDifferentReplyDoesNotMarkCurrentReply() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + tracker.observe(new ModelCallStartEvent("reply-1")); + + ReplyLifecycleTracker.Observation unrelated = + tracker.observe(new ToolCallStartEvent("reply-2", "tool-1", "search")); + ReplyLifecycleTracker.Observation current = + tracker.observe(new ToolCallStartEvent("reply-1", "tool-2", "search")); + + assertFalse(unrelated.currentReplyEvent()); + assertFalse(unrelated.after().toolCallSeen()); + assertTrue(current.currentReplyEvent()); + assertTrue(current.after().toolCallSeen()); + } + + @Test + void modelStartExposesPreviousReplyBeforeResettingState() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + tracker.observe(new ModelCallStartEvent("reply-1")); + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "draft")); + tracker.markDispositionEmitted(ReplyLifecycleTracker.SourceKey.topLevel()); + + ReplyLifecycleTracker.Observation next = + tracker.observe(new ModelCallStartEvent("reply-2")); + + assertEquals("reply-1", next.before().replyId()); + assertTrue(next.before().textSeen()); + assertTrue(next.before().dispositionEmitted()); + assertEquals("reply-2", next.after().replyId()); + assertFalse(next.after().textSeen()); + assertFalse(next.after().dispositionEmitted()); + } +} From da5a6f0d76f38dd5c1a497ee2685513cfa9d57aa Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:26:32 +0800 Subject: [PATCH 03/22] =?UTF-8?q?fix(core):=20=E4=BF=AE=E6=AD=A3=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E4=BA=8B=E4=BB=B6=E7=9A=84=E9=A1=B6=E5=B1=82=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit d09f6b5c87554f896a0bbe931003dd219b289fb9) --- .../core/internal/stream/ReplyLifecycleTracker.java | 2 +- .../internal/stream/ReplyLifecycleTrackerTest.java | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java index 4c32500b7e..462f4630a3 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java +++ b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java @@ -60,7 +60,7 @@ public static SourceKey topLevel() { } public boolean isTopLevel() { - return source.isEmpty(); + return source.isEmpty() && taskId.isEmpty(); } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java index 6db629956c..a7f4f809ad 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java @@ -43,6 +43,16 @@ void sourceKeyUsesTaskIdToIsolateConcurrentCallsFromSameSource() { assertNotEquals(tracker.sourceKey(taskA), tracker.sourceKey(taskB)); } + @Test + void taskScopedEventWithoutSourceIsNotTreatedAsTopLevel() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + AgentEvent event = + new ModelCallStartEvent("reply-1") + .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-1"); + + assertFalse(tracker.sourceKey(event).isTopLevel()); + } + @Test void nonEmptyTextDeltaMarksOnlyItsCurrentReplyAsVisible() { ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); From 74e204fe0628fa6689b6474acedfb934a4dc81bf Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:40:32 +0800 Subject: [PATCH 04/22] =?UTF-8?q?feat(core):=20=E5=A2=9E=E5=8A=A0=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=96=87=E6=9C=AC=E5=A4=84=E7=BD=AE=E6=A0=87=E6=B3=A8?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 3ba05d8565dc9654795deccfd9baa6b062f0c345) --- .../core/event/AgentEventStreams.java | 188 +++++++++ .../core/event/AgentEventStreamsTest.java | 366 ++++++++++++++++++ 2 files changed, 554 insertions(+) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java new file mode 100644 index 0000000000..0bcc38dd31 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java @@ -0,0 +1,188 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.event; + +import io.agentscope.core.internal.stream.ReplyLifecycleTracker; +import io.agentscope.core.internal.stream.ReplyLifecycleTracker.Observation; +import io.agentscope.core.internal.stream.ReplyLifecycleTracker.ReplySnapshot; +import io.agentscope.core.internal.stream.ReplyLifecycleTracker.SourceKey; +import io.agentscope.core.message.GenerateReason; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import reactor.core.publisher.Flux; + +/** Utilities for deriving optional lifecycle signals from an {@link AgentEvent} stream. */ +public final class AgentEventStreams { + + private AgentEventStreams() {} + + /** + * Adds text output disposition events without changing the source stream itself. + * + *

State is isolated per subscription and per {@code source + taskId}. The authoritative + * invocation result remains {@link AgentResultEvent}; a terminal disposition only closes the + * last visible reply before a normally completed {@link AgentEndEvent}. + * + * @param source source event stream + * @return a deferred stream containing the original events and derived disposition events + */ + public static Flux withTextOutputDisposition(Flux source) { + Objects.requireNonNull(source, "source"); + return Flux.defer(() -> new DispositionAnnotator().apply(source)); + } + + private static final class DispositionAnnotator { + + private final ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + private final Map pendingTopLevelEnds = new LinkedHashMap<>(); + private final Set endedSources = new HashSet<>(); + + private Flux apply(Flux source) { + Flux processed = + source.concatMap(event -> Flux.fromIterable(process(event)), 1); + return processed.concatWith(Flux.defer(() -> Flux.fromIterable(complete()))); + } + + private List process(AgentEvent event) { + SourceKey sourceKey = tracker.sourceKey(event); + if (endedSources.contains(sourceKey)) { + throw new IllegalStateException( + "Received event after AgentEndEvent for source " + sourceKey); + } + + Observation observation = tracker.observe(event); + return switch (observation.kind()) { + case MODEL_CALL_START -> onModelCallStart(event, observation); + case TOOL_CALL_START -> onToolCallStart(event, observation); + case TEXT_BLOCK_END -> onTextBlockEnd(event, observation); + case AGENT_END -> onAgentEnd((AgentEndEvent) event, observation); + default -> List.of(event); + }; + } + + private List onModelCallStart(AgentEvent event, Observation observation) { + ReplySnapshot previous = observation.before(); + if (hasUnclassifiedText(previous)) { + return List.of( + disposition( + previous.replyId(), + TextOutputDisposition.INTERMEDIATE, + null, + event), + event); + } + return List.of(event); + } + + private List onToolCallStart(AgentEvent event, Observation observation) { + ReplySnapshot current = observation.after(); + if (observation.currentReplyEvent() && hasUnclassifiedText(current)) { + tracker.markDispositionEmitted(observation.sourceKey()); + return List.of( + disposition( + current.replyId(), TextOutputDisposition.INTERMEDIATE, null, event), + event); + } + return List.of(event); + } + + private List onTextBlockEnd(AgentEvent event, Observation observation) { + ReplySnapshot current = observation.after(); + if (observation.currentReplyEvent() + && current.toolCallSeen() + && hasUnclassifiedText(current)) { + tracker.markDispositionEmitted(observation.sourceKey()); + return List.of( + event, + disposition( + current.replyId(), + TextOutputDisposition.INTERMEDIATE, + null, + event)); + } + return List.of(event); + } + + private List onAgentEnd(AgentEndEvent event, Observation observation) { + SourceKey sourceKey = observation.sourceKey(); + endedSources.add(sourceKey); + if (sourceKey.isTopLevel()) { + pendingTopLevelEnds.put(sourceKey, event); + return List.of(); + } + + ReplySnapshot current = observation.after(); + List output = new ArrayList<>(2); + if (observation.currentReplyEvent() && hasUnclassifiedText(current)) { + output.add( + disposition( + current.replyId(), TextOutputDisposition.TERMINAL, null, event)); + tracker.markDispositionEmitted(sourceKey); + } + output.add(event); + return output; + } + + private List complete() { + List output = new ArrayList<>(pendingTopLevelEnds.size() * 2); + for (Map.Entry entry : pendingTopLevelEnds.entrySet()) { + SourceKey sourceKey = entry.getKey(); + AgentEndEvent end = entry.getValue(); + ReplySnapshot current = tracker.snapshot(sourceKey); + AgentResultEvent result = current.lastResult(); + if (Objects.equals(end.getReplyId(), current.replyId()) + && hasUnclassifiedText(current) + && result != null + && result.getResult() != null) { + GenerateReason reason = result.getResult().getGenerateReason(); + output.add( + disposition( + current.replyId(), + TextOutputDisposition.TERMINAL, + reason, + end)); + tracker.markDispositionEmitted(sourceKey); + } + output.add(end); + tracker.clearSource(sourceKey); + } + pendingTopLevelEnds.clear(); + return output; + } + + private static boolean hasUnclassifiedText(ReplySnapshot snapshot) { + return snapshot.replyId() != null + && snapshot.textSeen() + && !snapshot.dispositionEmitted(); + } + + private static TextOutputDispositionEvent disposition( + String replyId, + TextOutputDisposition disposition, + GenerateReason generateReason, + AgentEvent trigger) { + TextOutputDispositionEvent event = + new TextOutputDispositionEvent(replyId, disposition, generateReason); + event.withSource(trigger.getSource()).withMetadata(trigger.getMetadata()); + return event; + } + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java new file mode 100644 index 0000000000..b1c7722ab4 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java @@ -0,0 +1,366 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.event; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import io.agentscope.core.message.AssistantMessage; +import io.agentscope.core.message.GenerateReason; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import reactor.test.publisher.TestPublisher; + +class AgentEventStreamsTest { + + @Test + void emitsResultTerminalThenEndOnNormalCompletion() { + AgentResultEvent result = result(GenerateReason.MODEL_STOP); + AgentEndEvent end = new AgentEndEvent("reply-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + new TextBlockEndEvent("reply-1", "block-1"), + new ModelCallEndEvent("reply-1", null), + result, + end)) + .collectList() + .block(); + + assertEquals(7, events.size()); + assertSame(result, events.get(4)); + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, events.get(5)); + assertEquals("reply-1", disposition.getReplyId()); + assertEquals(TextOutputDisposition.TERMINAL, disposition.getDisposition()); + assertEquals(GenerateReason.MODEL_STOP, disposition.getGenerateReason()); + assertSame(end, events.get(6)); + } + + @Test + void emitsIntermediateBeforeToolWhenVisibleTextPrecedesToolCall() { + ToolCallStartEvent tool = new ToolCallStartEvent("reply-1", "call-1", "search"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "checking"), + tool)) + .collectList() + .block(); + + assertEquals(4, events.size()); + assertDisposition(events.get(2), "reply-1", TextOutputDisposition.INTERMEDIATE); + assertSame(tool, events.get(3)); + } + + @Test + void emitsIntermediateAfterTextEndWhenToolCallPrecedesVisibleText() { + TextBlockEndEvent textEnd = new TextBlockEndEvent("reply-1", "block-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new ToolCallStartEvent("reply-1", "call-1", "search"), + new TextBlockDeltaEvent("reply-1", "block-1", "checking"), + textEnd)) + .collectList() + .block(); + + assertEquals(5, events.size()); + assertSame(textEnd, events.get(3)); + assertDisposition(events.get(4), "reply-1", TextOutputDisposition.INTERMEDIATE); + } + + @Test + void emitsIntermediateBeforeNextModelRoundWithoutToolCall() { + ModelCallStartEvent nextRound = new ModelCallStartEvent("reply-2"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "draft"), + new ModelCallEndEvent("reply-1", null), + nextRound)) + .collectList() + .block(); + + assertEquals(5, events.size()); + assertDisposition(events.get(3), "reply-1", TextOutputDisposition.INTERMEDIATE); + assertSame(nextRound, events.get(4)); + } + + @Test + void classifiesEachReplyAtMostOnceAcrossMultipleTextSegments() { + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "first"), + new ToolCallStartEvent("reply-1", "call-1", "search"), + new TextBlockEndEvent("reply-1", "block-1"), + new TextBlockDeltaEvent("reply-1", "block-2", "second"), + new TextBlockEndEvent("reply-1", "block-2"))) + .collectList() + .block(); + + assertEquals( + 1, events.stream().filter(TextOutputDispositionEvent.class::isInstance).count()); + } + + @Test + void isolatesSameSourceByTaskId() { + AgentEvent taskAStart = tagged(new ModelCallStartEvent("reply-a"), "worker", "task-a"); + AgentEvent taskAText = + tagged( + new TextBlockDeltaEvent("reply-a", "block-a", "draft-a"), + "worker", + "task-a"); + AgentEvent taskBStart = tagged(new ModelCallStartEvent("reply-b"), "worker", "task-b"); + AgentEvent taskBText = + tagged( + new TextBlockDeltaEvent("reply-b", "block-b", "draft-b"), + "worker", + "task-b"); + AgentEvent taskBTool = + tagged(new ToolCallStartEvent("reply-b", "call-b", "search"), "worker", "task-b"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just(taskAStart, taskAText, taskBStart, taskBText, taskBTool)) + .collectList() + .block(); + + assertEquals(6, events.size()); + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, events.get(4)); + assertEquals("reply-b", disposition.getReplyId()); + assertEquals("worker", disposition.getSource()); + assertEquals("task-b", disposition.getMetadata().get(AgentEvent.METADATA_TASK_ID)); + assertSame(taskBTool, events.get(5)); + } + + @Test + void createsIndependentStateForEachSubscription() { + Flux annotated = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "draft"), + new ToolCallStartEvent("reply-1", "call-1", "search"))); + + List first = annotated.map(AgentEvent::getType).collectList().block(); + List second = annotated.map(AgentEvent::getType).collectList().block(); + + assertEquals(first, second); + assertEquals(AgentEventType.TEXT_OUTPUT_DISPOSITION, first.get(2)); + } + + @Test + void emitsTopLevelEndWithoutTerminalWhenNoAuthoritativeResultExists() { + AgentEndEvent end = new AgentEndEvent("reply-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + end)) + .collectList() + .block(); + + assertEquals(3, events.size()); + assertSame(end, events.get(2)); + } + + @Test + void doesNotLeakPendingTopLevelEndOrTerminalOnError() { + RuntimeException failure = new RuntimeException("boom"); + AgentEndEvent end = new AgentEndEvent("reply-1"); + + Flux annotated = + AgentEventStreams.withTextOutputDisposition( + Flux.concat( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + result(GenerateReason.MODEL_STOP), + end), + Flux.error(failure))); + + StepVerifier.create(annotated) + .expectNextCount(3) + .expectErrorMatches(error -> error == failure) + .verify(); + } + + @Test + void cancellationAfterResultDoesNotSynthesizeTerminalDisposition() { + TestPublisher source = TestPublisher.create(); + AgentResultEvent result = result(GenerateReason.MODEL_STOP); + + StepVerifier.create(AgentEventStreams.withTextOutputDisposition(source.flux())) + .then( + () -> + source.next( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + result)) + .expectNextCount(2) + .expectNext(result) + .thenCancel() + .verify(); + + source.assertCancelled(); + } + + @Test + void rejectsEventsAfterTopLevelEndWithoutLeakingHeldEvents() { + AgentResultEvent lateResult = result(GenerateReason.MODEL_STOP); + + StepVerifier.create( + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + new AgentEndEvent("reply-1"), + lateResult))) + .expectNextCount(2) + .expectErrorMatches( + error -> + error instanceof IllegalStateException + && error.getMessage().contains("after AgentEndEvent")) + .verify(); + } + + @Test + void respectsOneAtATimeDownstreamDemandForDerivedEvents() { + AtomicBoolean completed = new AtomicBoolean(); + AgentEndEvent end = new AgentEndEvent("reply-1"); + Flux annotated = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + result(GenerateReason.STRUCTURED_OUTPUT), + end)) + .doOnComplete(() -> completed.set(true)); + + StepVerifier.create(annotated, 0) + .thenRequest(1) + .expectNextMatches(ModelCallStartEvent.class::isInstance) + .thenRequest(1) + .expectNextMatches(TextBlockDeltaEvent.class::isInstance) + .thenRequest(1) + .expectNextMatches(AgentResultEvent.class::isInstance) + .thenRequest(1) + .assertNext( + event -> { + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, event); + assertEquals( + GenerateReason.STRUCTURED_OUTPUT, + disposition.getGenerateReason()); + }) + .then(() -> assertEquals(false, completed.get())) + .thenRequest(1) + .expectNext(end) + .verifyComplete(); + } + + @Test + void childEndImmediatelyClosesVisibleReplyWithTerminalDisposition() { + AgentEndEvent end = + (AgentEndEvent) tagged(new AgentEndEvent("reply-1"), "worker", "task-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + end)) + .collectList() + .block(); + + assertEquals(4, events.size()); + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, events.get(2)); + assertEquals(TextOutputDisposition.TERMINAL, disposition.getDisposition()); + assertNull(disposition.getGenerateReason()); + assertEquals("worker", disposition.getSource()); + assertEquals("task-1", disposition.getMetadata().get(AgentEvent.METADATA_TASK_ID)); + assertSame(end, events.get(3)); + } + + @Test + void cancellationAfterChildTerminalCanPreventFollowingEnd() { + AgentEndEvent end = + (AgentEndEvent) tagged(new AgentEndEvent("reply-1"), "worker", "task-1"); + + StepVerifier.create( + AgentEventStreams.withTextOutputDisposition( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + end))) + .expectNextCount(2) + .expectNextMatches(TextOutputDispositionEvent.class::isInstance) + .thenCancel() + .verify(); + } + + private static AgentResultEvent result(GenerateReason reason) { + return new AgentResultEvent( + AssistantMessage.builder().textContent("answer").generateReason(reason).build()); + } + + private static AgentEvent tagged(AgentEvent event, String source, String taskId) { + return event.withSource(source).withMetadataEntry(AgentEvent.METADATA_TASK_ID, taskId); + } + + private static void assertDisposition( + AgentEvent event, String replyId, TextOutputDisposition expected) { + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, event); + assertEquals(replyId, disposition.getReplyId()); + assertEquals(expected, disposition.getDisposition()); + } +} From 73bf32019c9fddae7cc803d9823e19c1bb56fded Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:59:29 +0800 Subject: [PATCH 05/22] =?UTF-8?q?test(core):=20=E8=A1=A5=E5=85=85=E6=B5=81?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=E5=99=A8=E8=BE=B9=E7=95=8C=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit c788fe9bfa353422280e2a9ac4bb7137e9b39f34) --- .../core/event/AgentEventStreamsTest.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java index b1c7722ab4..274827481d 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.agentscope.core.message.AssistantMessage; import io.agentscope.core.message.GenerateReason; @@ -197,6 +198,26 @@ void emitsTopLevelEndWithoutTerminalWhenNoAuthoritativeResultExists() { assertSame(end, events.get(2)); } + @Test + void emitsTopLevelEndWithoutTerminalWhenAuthoritativeResultIsNull() { + AgentResultEvent result = new AgentResultEvent(null); + AgentEndEvent end = new AgentEndEvent("reply-1"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + result, + end)) + .collectList() + .block(); + + assertEquals(4, events.size()); + assertSame(result, events.get(2)); + assertSame(end, events.get(3)); + } + @Test void doesNotLeakPendingTopLevelEndOrTerminalOnError() { RuntimeException failure = new RuntimeException("boom"); @@ -238,6 +259,46 @@ void cancellationAfterResultDoesNotSynthesizeTerminalDisposition() { source.assertCancelled(); } + @Test + void cancellationAfterTopLevelEndIsStagedDoesNotLeakTerminalOrEnd() { + TestPublisher source = TestPublisher.create(); + AgentResultEvent result = result(GenerateReason.MODEL_STOP); + AgentEndEvent end = new AgentEndEvent("reply-1"); + AtomicBoolean cancellationRequested = new AtomicBoolean(); + + Flux annotated = + AgentEventStreams.withTextOutputDisposition( + source.flux() + .doOnCancel( + () -> + assertTrue( + cancellationRequested.get(), + "source cancelled before verifier" + + " cancellation"))); + + StepVerifier.create(annotated, 0) + .thenRequest(1) + .then(() -> source.next(new ModelCallStartEvent("reply-1"))) + .expectNextMatches(ModelCallStartEvent.class::isInstance) + .thenRequest(1) + .then(() -> source.next(new TextBlockDeltaEvent("reply-1", "block-1", "answer"))) + .expectNextMatches(TextBlockDeltaEvent.class::isInstance) + .thenRequest(1) + .then(() -> source.next(result)) + .expectNext(result) + .then( + () -> { + source.next(end); + source.assertNoRequestOverflow(); + source.assertSubscribers(); + }) + .then(() -> cancellationRequested.set(true)) + .thenCancel() + .verify(); + + source.assertCancelled(); + } + @Test void rejectsEventsAfterTopLevelEndWithoutLeakingHeldEvents() { AgentResultEvent lateResult = result(GenerateReason.MODEL_STOP); From a9797554f6f0eeeb31d6c44c52894f8d85ac68dc Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:05:35 +0800 Subject: [PATCH 06/22] =?UTF-8?q?test(core):=20=E5=BC=BA=E5=8C=96=E9=A1=B6?= =?UTF-8?q?=E5=B1=82=E7=BB=93=E6=9D=9F=E5=8F=96=E6=B6=88=E5=B1=8F=E9=9A=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 0fb5206121c007f5027a912d299c69443a39e925) --- .../core/event/AgentEventStreamsTest.java | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java index 274827481d..776856388c 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java @@ -19,7 +19,6 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; import io.agentscope.core.message.AssistantMessage; import io.agentscope.core.message.GenerateReason; @@ -264,19 +263,10 @@ void cancellationAfterTopLevelEndIsStagedDoesNotLeakTerminalOrEnd() { TestPublisher source = TestPublisher.create(); AgentResultEvent result = result(GenerateReason.MODEL_STOP); AgentEndEvent end = new AgentEndEvent("reply-1"); - AtomicBoolean cancellationRequested = new AtomicBoolean(); + AgentEvent barrier = + tagged(new ModelCallStartEvent("barrier-reply"), "barrier-source", "barrier-task"); - Flux annotated = - AgentEventStreams.withTextOutputDisposition( - source.flux() - .doOnCancel( - () -> - assertTrue( - cancellationRequested.get(), - "source cancelled before verifier" - + " cancellation"))); - - StepVerifier.create(annotated, 0) + StepVerifier.create(AgentEventStreams.withTextOutputDisposition(source.flux()), 0) .thenRequest(1) .then(() -> source.next(new ModelCallStartEvent("reply-1"))) .expectNextMatches(ModelCallStartEvent.class::isInstance) @@ -286,13 +276,10 @@ void cancellationAfterTopLevelEndIsStagedDoesNotLeakTerminalOrEnd() { .thenRequest(1) .then(() -> source.next(result)) .expectNext(result) - .then( - () -> { - source.next(end); - source.assertNoRequestOverflow(); - source.assertSubscribers(); - }) - .then(() -> cancellationRequested.set(true)) + .thenRequest(1) + .then(() -> source.next(end)) + .then(() -> source.next(barrier)) + .expectNext(barrier) .thenCancel() .verify(); From 28aecf939b49a1b1e1bf47fa56d1fe409084fa08 Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:21:40 +0800 Subject: [PATCH 07/22] =?UTF-8?q?fix(harness):=20=E4=B8=BA=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=B5=81=E4=BA=8B=E4=BB=B6=E9=99=84=E5=8A=A0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 6e3b5a1e2e79d3e24b8307ad4c3af2af0f325ee4) --- .../harness/agent/tool/AgentSpawnTool.java | 38 +++-- .../HarnessAgentSubagentStreamEventsTest.java | 135 ++++++++++++++++-- .../tool/AgentSpawnToolRemoteHelpersTest.java | 12 ++ 3 files changed, 167 insertions(+), 18 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 e1fd5c6bcc..45922f4437 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 @@ -771,7 +771,8 @@ private Mono execLocalSync( String userId, String prompt, SpawnedAgent spawned, - RuntimeContext parentCtx) { + RuntimeContext parentCtx, + String taskId) { return Mono.deferContextual( ctxView -> { DefaultAgentManager manager = managerFor(parentCtx); @@ -782,18 +783,26 @@ private Mono execLocalSync( String sourcePath = buildSourcePath(spawned, parentCtx); String replyId = UUID.randomUUID().toString().replace("-", ""); AgentEventEmitter taggedEmitter = - event -> parentEmitter.emit(event.withSource(sourcePath)); + event -> + parentEmitter.emit( + tagForwardedEvent(event, sourcePath, taskId)); parentEmitter.emit( - new AgentStartEvent(spawned.sessionId(), replyId, spawned.agentId()) - .withSource(sourcePath)); + tagForwardedEvent( + new AgentStartEvent( + spawned.sessionId(), replyId, spawned.agentId()), + sourcePath, + taskId)); AtomicBoolean endEmitted = new AtomicBoolean(); Runnable emitEnd = () -> { if (endEmitted.compareAndSet(false, true)) { parentEmitter.emit( - new AgentEndEvent(replyId).withSource(sourcePath)); + tagForwardedEvent( + new AgentEndEvent(replyId), + sourcePath, + taskId)); } }; @@ -895,6 +904,7 @@ private Mono execWithTimeoutPromotion( Mono.create( sink -> { CompletableFuture bridge = new CompletableFuture<>(); + String taskId = "task_" + UUID.randomUUID(); Mono inner = execLocalSync( @@ -903,7 +913,8 @@ private Mono execWithTimeoutPromotion( userId, task, spawned, - runtimeContext) + runtimeContext, + taskId) .contextWrite( c -> reactor.util.context.Context.of( @@ -954,6 +965,7 @@ private Mono execWithTimeoutPromotion( header, timeoutMs, agentId, + taskId, sink, forceSync, innerSub); @@ -1005,6 +1017,7 @@ private void handleExecError( String header, long timeoutMs, String agentId, + String taskId, reactor.core.publisher.MonoSink sink, boolean forceSync, Disposable innerSub) { @@ -1024,7 +1037,6 @@ private void handleExecError( sink.success(header + "\n" + formatForceSyncTimeout(timeoutMs)); return; } - String taskId = "task_" + UUID.randomUUID(); String parentSessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; CompletableFuture textFuture = bridge.thenApply(AgentSpawnTool::textOf); taskRepository.putTask( @@ -1195,6 +1207,15 @@ static String buildRemoteSourcePath(String parentSessionId, String agentId) { */ static AgentEvent tagRemoteForwardedEvent( AgentEvent event, String sourcePath, String taskId, String parentSessionId) { + event = tagForwardedEvent(event, sourcePath, taskId); + if (event == null) return null; + if (parentSessionId != null && !parentSessionId.isBlank()) { + event.withMetadataEntry(AgentEvent.METADATA_PARENT_SESSION_ID, parentSessionId.trim()); + } + return event; + } + + static AgentEvent tagForwardedEvent(AgentEvent event, String sourcePath, String taskId) { if (event == null) { return null; } @@ -1202,9 +1223,6 @@ static AgentEvent tagRemoteForwardedEvent( if (taskId != null && !taskId.isBlank()) { event.withMetadataEntry(AgentEvent.METADATA_TASK_ID, taskId); } - if (parentSessionId != null && !parentSessionId.isBlank()) { - event.withMetadataEntry(AgentEvent.METADATA_PARENT_SESSION_ID, parentSessionId.trim()); - } return event; } 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 115dae5351..f05df63257 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 @@ -39,8 +39,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -97,15 +100,18 @@ private static ChatResponse stopChunk(String id, String text) { } private static ChatResponse toolCallChunk(String id, String toolName, Map in) { + return new ChatResponse( + id, List.of(toolCallBlock(id, toolName, in)), null, Map.of(), "tool_use"); + } + + private static ToolUseBlock toolCallBlock(String id, String toolName, Map in) { String contentJson = io.agentscope.core.util.JsonUtils.getJsonCodec().toJson(in); - ToolUseBlock tc = - ToolUseBlock.builder() - .id("tc-" + id) - .name(toolName) - .input(in) - .content(contentJson) - .build(); - return new ChatResponse(id, List.of(tc), null, Map.of(), "tool_use"); + return ToolUseBlock.builder() + .id("tc-" + id) + .name(toolName) + .input(in) + .content(contentJson) + .build(); } private void writeSubagentSpec(String childId, String description, String body) @@ -197,6 +203,119 @@ void streamEvents_childEventsForwardedWithSource() throws Exception { "expected at least one parent event with source == null"); } + @Test + void streamEvents_sameSourceConcurrentChildCallsHaveDistinctTaskIds() throws Exception { + String childId = "worker"; + writeSubagentSpec(childId, "Worker", "Complete the assigned task."); + + Model model = mock(Model.class); + AtomicInteger streamCalls = new AtomicInteger(); + when(model.getModelName()).thenReturn("stub"); + when(model.stream(anyList(), any(), any())) + .thenAnswer( + ignored -> { + int call = streamCalls.getAndIncrement(); + if (call == 0) { + return Flux.just( + new ChatResponse( + "p1", + List.of( + toolCallBlock( + "first", + "agent_spawn", + Map.of( + "agent_id", + childId, + "task", + "first task", + "timeout_seconds", + 60)), + toolCallBlock( + "second", + "agent_spawn", + Map.of( + "agent_id", + childId, + "task", + "second task", + "timeout_seconds", + 60))), + null, + Map.of(), + "tool_use")); + } + if (call <= 2) { + return Flux.just(stopChunk("child-" + call, "completed")); + } + return Flux.just(stopChunk("parent-final", "done")); + }); + + parent = + HarnessAgent.builder() + .name("parent") + .model(model) + .workspace(workspace) + .abstractFilesystem(new LocalFilesystem(workspace)) + .build(); + + List events = + parent.streamEvents( + List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("start") + .build()), + RuntimeContext.builder().sessionId("sess-shared-source").build()) + .collectList() + .block(); + + assertNotNull(events); + List childEvents = + events.stream() + .filter( + event -> + ("sess-shared-source/" + childId).equals(event.getSource())) + .collect(Collectors.toList()); + assertFalse( + childEvents.isEmpty(), + "expected events forwarded from both child calls; got: " + + events.stream() + .map(event -> event.getType() + "(src=" + event.getSource() + ")") + .collect(Collectors.joining(", "))); + assertTrue( + childEvents.stream() + .allMatch( + event -> + event.getMetadata() != null + && event.getMetadata() + .containsKey(AgentEvent.METADATA_TASK_ID)), + "every forwarded child event must carry its taskId"); + + Set taskIds = + childEvents.stream() + .map(event -> event.getMetadata().get(AgentEvent.METADATA_TASK_ID)) + .collect(Collectors.toCollection(HashSet::new)); + assertEquals(2, taskIds.size(), "same-source calls must retain distinct taskIds"); + for (Object taskId : taskIds) { + List taskEvents = + childEvents.stream() + .filter( + event -> + taskId.equals( + event.getMetadata() + .get(AgentEvent.METADATA_TASK_ID))) + .collect(Collectors.toList()); + assertTrue( + taskEvents.stream() + .anyMatch(event -> event.getType() == AgentEventType.AGENT_START), + "each task must have a tagged child start event"); + assertTrue( + taskEvents.stream() + .anyMatch(event -> event.getType() == AgentEventType.AGENT_END), + "each task must have a tagged child end event"); + } + } + // ----------------------------------------------------------------- // 1b. streamEvents() under AsyncToolMiddleware — child events still // forwarded with source tag (regression for the bare-subscribe diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRemoteHelpersTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRemoteHelpersTest.java index ad98621307..a5f534c3dd 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRemoteHelpersTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolRemoteHelpersTest.java @@ -81,6 +81,18 @@ void tagRemoteForwardedEvent_skipsBlankParentSessionId() { .containsKey(AgentEvent.METADATA_PARENT_SESSION_ID)); } + @Test + void tagForwardedEvent_setsSourceAndTaskIdWithoutDiscardingMetadata() { + TextBlockDeltaEvent event = new TextBlockDeltaEvent(null, "b1", "hello"); + event.withMetadataEntry("keep", "me"); + + AgentEvent tagged = AgentSpawnTool.tagForwardedEvent(event, "parent/worker", "task_abc"); + + assertEquals("parent/worker", tagged.getSource()); + assertEquals("task_abc", tagged.getMetadata().get(AgentEvent.METADATA_TASK_ID)); + assertEquals("me", tagged.getMetadata().get("keep")); + } + @Test void collectParentDenyRules_returnsEmptyWhenInheritDisabled() { AgentState parent = From d707d8192624ff8f925a7167a20da6c2c68eabe6 Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:40:06 +0800 Subject: [PATCH 08/22] =?UTF-8?q?feat(protocol):=20=E9=80=8F=E4=BC=A0?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=A4=84=E7=BD=AE=E5=92=8C=E6=9D=83=E5=A8=81?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 61f58ae38cf3bc7b9c01c7c329630369e10e6beb) --- .../AgentProtocolStreamDetailTest.java | 24 +++++++++++---- .../subagent/protocol/RemoteEventCodec.java | 30 +++++++++++++++++-- .../RemoteEventCodecPassthroughTest.java | 5 ++++ .../protocol/RemoteEventCodecTest.java | 18 +++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java index 0da7c14bcf..c3bf10f58c 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java @@ -31,6 +31,8 @@ import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.TextOutputDisposition; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ToolResultTextDeltaEvent; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; @@ -77,6 +79,7 @@ private static List agentRun() { new TextBlockStartEvent("reply", "b1"), new TextBlockDeltaEvent("reply", "b1", "hello"), new TextBlockEndEvent("reply", "b1"), + new TextOutputDispositionEvent("reply", TextOutputDisposition.TERMINAL, null), new ToolResultTextDeltaEvent("reply", "call-1", "read_file", "file contents"), new ModelCallEndEvent("reply", new ChatUsage(10, 20, 0, 0.5)), new AgentResultEvent( @@ -95,13 +98,24 @@ void statusLevelCarriesLifecycleOnly() { } @Test - void fullLevelAddsDeltasButNotPassthrough() { - Set types = typesFor("full", "t-full"); + void fullLevelAddsDeltasAndAuthoritativePassthroughEvents() { + List events = collect("full", "t-full"); + Set types = + events.stream() + .map(RemoteAgentEvent::getType) + .collect(Collectors.toUnmodifiableSet()); + Set eventTypes = + events.stream() + .map(RemoteAgentEvent::getEventType) + .filter(Objects::nonNull) + .collect(Collectors.toUnmodifiableSet()); assertTrue(types.contains(RemoteEventType.TEXT_DELTA)); - assertFalse( - types.contains(RemoteEventType.AGENT_EVENT), - "full keeps the pre-existing volume for deployments that never asked for more"); + assertTrue(types.contains(RemoteEventType.AGENT_EVENT)); + assertTrue(eventTypes.contains("TEXT_OUTPUT_DISPOSITION"), eventTypes.toString()); + assertTrue(eventTypes.contains("AGENT_RESULT"), eventTypes.toString()); + assertFalse(eventTypes.contains("TEXT_BLOCK_START"), eventTypes.toString()); + assertFalse(eventTypes.contains("TOOL_RESULT_TEXT_DELTA"), eventTypes.toString()); } @Test diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java index b2d5731e9f..8cbcd90968 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java @@ -20,9 +20,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentEventType; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ThinkingBlockDeltaEvent; import io.agentscope.core.event.ToolCallEndEvent; import io.agentscope.core.event.ToolCallStartEvent; @@ -74,7 +76,12 @@ public static Optional fromAgentEvent(AgentEvent event) { if (event.getType() != null) { dto.setEventType(event.getType().name()); } - dto.setPayload(serializeEvent(event)); + String payload = serializeEvent(event); + if (event instanceof TextOutputDispositionEvent && (payload == null || payload.isBlank())) { + log.warn("Dropping text output disposition event because its payload is unavailable"); + return Optional.empty(); + } + dto.setPayload(payload); return Optional.of(withTypedFields(dto, event)); } @@ -232,9 +239,26 @@ public static boolean matchesDetail(RemoteEventType type, String detail) { }; } - /** Detail check for a whole DTO; equivalent to {@link #matchesDetail(RemoteEventType, String)}. */ + /** + * Detail check for a whole DTO. At {@code full}, disposition and result passthrough events are + * included while other {@link RemoteEventType#AGENT_EVENT} values remain verbose-only. + */ public static boolean matchesDetail(RemoteAgentEvent event, String detail) { - return event != null && matchesDetail(event.getType(), detail); + if (event == null) { + return false; + } + if (event.getType() != RemoteEventType.AGENT_EVENT) { + return matchesDetail(event.getType(), detail); + } + RemoteStreamDetail level = RemoteStreamDetail.parse(detail); + return level.includes(RemoteStreamDetail.VERBOSE) + || (level.includes(RemoteStreamDetail.FULL) + && isAuthoritativeFullEvent(event.getEventType())); + } + + private static boolean isAuthoritativeFullEvent(String eventType) { + return AgentEventType.TEXT_OUTPUT_DISPOSITION.name().equals(eventType) + || AgentEventType.AGENT_RESULT.name().equals(eventType); } private static RequireUserConfirmEvent toRequireConfirm(RemoteAgentEvent remote) { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecPassthroughTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecPassthroughTest.java index 40980429e3..7f3f3a12eb 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecPassthroughTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecPassthroughTest.java @@ -44,6 +44,8 @@ import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.TextOutputDisposition; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ThinkingBlockDeltaEvent; import io.agentscope.core.event.ThinkingBlockEndEvent; import io.agentscope.core.event.ThinkingBlockStartEvent; @@ -107,6 +109,9 @@ private static Map sampleEvents() { events.put(AgentEventType.TEXT_BLOCK_START, new TextBlockStartEvent("reply", "b1")); events.put(AgentEventType.TEXT_BLOCK_DELTA, new TextBlockDeltaEvent("reply", "b1", "hi")); events.put(AgentEventType.TEXT_BLOCK_END, new TextBlockEndEvent("reply", "b1")); + events.put( + AgentEventType.TEXT_OUTPUT_DISPOSITION, + new TextOutputDispositionEvent("reply", TextOutputDisposition.TERMINAL, null)); events.put(AgentEventType.THINKING_BLOCK_START, new ThinkingBlockStartEvent("reply", "b2")); events.put( AgentEventType.THINKING_BLOCK_DELTA, diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecTest.java index cc0178dc57..6fb2e4a9a5 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodecTest.java @@ -25,6 +25,8 @@ import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.TextBlockDeltaEvent; +import io.agentscope.core.event.TextOutputDisposition; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ToolCallStartEvent; import io.agentscope.core.message.ToolCallState; import io.agentscope.core.message.ToolUseBlock; @@ -116,6 +118,22 @@ void detailFilterHidesTextUnlessFull() { assertTrue(RemoteEventCodec.matchesDetail(RemoteEventType.REQUIRE_CONFIRM, null)); } + @Test + void roundTripTextOutputDispositionAsAgentEventPayload() { + RemoteAgentEvent remote = + RemoteEventCodec.fromAgentEvent( + new TextOutputDispositionEvent( + "reply-1", TextOutputDisposition.INTERMEDIATE, null)) + .orElseThrow(); + + assertEquals(RemoteEventType.AGENT_EVENT, remote.getType()); + assertEquals("TEXT_OUTPUT_DISPOSITION", remote.getEventType()); + assertInstanceOf(String.class, remote.getPayload()); + assertInstanceOf( + TextOutputDispositionEvent.class, + RemoteEventCodec.toAgentEvent(remote).orElseThrow()); + } + @Test void unknownInternalEventsDropped() { // Custom/other events without a codec mapping From 27a387733ec2cee4f85973daea39c08f75cc666a Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:19:23 +0800 Subject: [PATCH 09/22] =?UTF-8?q?feat(agui):=20=E6=94=AF=E6=8C=81=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=96=87=E6=9C=AC=E5=A4=84=E7=BD=AE=E4=B8=8E=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 1c8149e45c41d2722b862b8ca83f7c77062ed83a) --- .../core/agui/adapter/AguiAdapterConfig.java | 28 ++ .../core/agui/adapter/AguiAgentAdapter.java | 41 ++- .../adapter/strategy/AguiStreamContext.java | 102 +++++++- .../TextOutputDispositionConverter.java | 69 +++++ .../agui/adapter/AguiAdapterConfigTest.java | 15 ++ .../agui/adapter/AguiAgentAdapterV2Test.java | 247 ++++++++++++++++++ 6 files changed, 492 insertions(+), 10 deletions(-) create mode 100644 agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java index cb9211274f..ea464494fc 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java @@ -38,6 +38,7 @@ public class AguiAdapterConfig { private final boolean emitToolCallArgs; private final boolean emitTokenUsage; private final boolean enableReasoning; + private final boolean textOutputDispositionEnabled; private final boolean emitRunFinishedAfterError; private final Duration runTimeout; private final String defaultAgentId; @@ -52,6 +53,7 @@ private AguiAdapterConfig(Builder builder) { this.emitToolCallArgs = builder.emitToolCallArgs; this.emitTokenUsage = builder.emitTokenUsage; this.enableReasoning = builder.enableReasoning; + this.textOutputDispositionEnabled = builder.textOutputDispositionEnabled; this.emitRunFinishedAfterError = builder.emitRunFinishedAfterError; this.runTimeout = builder.runTimeout; this.defaultAgentId = builder.defaultAgentId; @@ -113,6 +115,18 @@ public boolean isEnableReasoning() { return enableReasoning; } + /** + * Check whether streamed text output disposition and final message snapshots are enabled. + * + *

Default is {@code false} so existing AG-UI event sequences and message IDs remain + * unchanged. + * + * @return true to derive text output disposition events + */ + public boolean isTextOutputDispositionEnabled() { + return textOutputDispositionEnabled; + } + /** * Check whether {@code RUN_FINISHED} should be emitted after {@code RUN_ERROR}. * @@ -220,6 +234,7 @@ public static class Builder { private boolean emitToolCallArgs = true; private boolean emitTokenUsage = false; private boolean enableReasoning = false; + private boolean textOutputDispositionEnabled = false; private boolean emitRunFinishedAfterError = false; private Duration runTimeout = Duration.ofMinutes(10); private String defaultAgentId; @@ -290,6 +305,19 @@ public Builder enableReasoning(boolean enableReasoning) { return this; } + /** + * Set whether to derive text output disposition events and final message snapshots. + * + *

Default is {@code false} for backward compatibility. + * + * @param textOutputDispositionEnabled true to enable disposition conversion + * @return This builder + */ + public Builder textOutputDispositionEnabled(boolean textOutputDispositionEnabled) { + this.textOutputDispositionEnabled = textOutputDispositionEnabled; + return this; + } + /** * Set whether to emit {@code RUN_FINISHED} after {@code RUN_ERROR}. * diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java index bd2b0d97b0..7ca238be74 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java @@ -22,14 +22,17 @@ import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.agent.StreamOptions; import io.agentscope.core.agui.AguiUtil; +import io.agentscope.core.agui.adapter.strategy.AgentEventConverter; import io.agentscope.core.agui.adapter.strategy.AgentEventConverterRegistry; import io.agentscope.core.agui.adapter.strategy.AguiStreamContext; +import io.agentscope.core.agui.adapter.strategy.TextOutputDispositionConverter; import io.agentscope.core.agui.converter.AguiMessageConverter; import io.agentscope.core.agui.converter.AguiToolConverter; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.RunAgentInput; import io.agentscope.core.agui.model.ToolMergeMode; import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentEventStreams; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; @@ -106,9 +109,14 @@ public AguiAgentAdapter(Agent agent, AguiAdapterConfig config) { this.config = Objects.requireNonNull(config, "config cannot be null"); this.messageConverter = new AguiMessageConverter(); this.toolConverter = new AguiToolConverter(); + List eventConverters = new ArrayList<>(); + if (config.isTextOutputDispositionEnabled()) { + eventConverters.add(new TextOutputDispositionConverter()); + } + eventConverters.addAll(config.getEventConverters()); this.agentEventConverterRegistry = new AgentEventConverterRegistry( - config.getEventConverters(), + eventConverters, config.getEventEnrichers(), config.isEmitSubagentEventsAsNative()); } @@ -203,20 +211,34 @@ private AgentStream streamWithRuntimeContext( if (agent instanceof ReActAgent reAct) { AguiStreamContext context = - new AguiStreamContext(threadId, runId, config, input, externalToolDetector()); + new AguiStreamContext( + threadId, + runId, + config, + input, + externalToolDetector(), + () -> authoritativeMessages(runtimeContext)); Flux events = Objects.requireNonNull( reAct.streamEvents(msgs, runtimeContext), "agent stream is null"); + events = applyTextOutputDisposition(events); return new AgentStream( convertAgentEvents(events, context), () -> finishPendingEvents(context)); } if (AguiUtil.isHarnessAgent(agent)) { AguiStreamContext context = - new AguiStreamContext(threadId, runId, config, input, externalToolDetector()); + new AguiStreamContext( + threadId, + runId, + config, + input, + externalToolDetector(), + () -> authoritativeMessages(runtimeContext)); Flux events = Objects.requireNonNull( invokeHarnessStreamEvents(agent, msgs, runtimeContext), "agent stream is null"); + events = applyTextOutputDisposition(events); return new AgentStream( convertAgentEvents(events, context), () -> finishPendingEvents(context)); } @@ -236,7 +258,7 @@ private AgentStream streamWithRuntimeContext( } private Flux convertAgentEvents(Flux events, AguiStreamContext context) { - return events + return events.doOnNext(context::observe) // Use concatMapIterable to preserve strict event ordering .concatMapIterable(event -> agentEventConverterRegistry.convert(event, context)) .onErrorResume( @@ -248,6 +270,17 @@ private Flux finishPendingEvents(AguiStreamContext context) { agentEventConverterRegistry.enrich(null, context.finishPendingEvents(), context)); } + private Flux applyTextOutputDisposition(Flux events) { + return config.isTextOutputDispositionEnabled() + ? AgentEventStreams.withTextOutputDisposition(events) + : events; + } + + private List authoritativeMessages(RuntimeContext runtimeContext) { + var state = RuntimeContext.resolveAgentState(runtimeContext, agent); + return state != null ? state.getContext() : List.of(); + } + private record AgentStream(Flux events, Supplier> finish) {} @SuppressWarnings("unchecked") diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java index 9f76aa8d18..91349c0afe 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java @@ -16,10 +16,14 @@ package io.agentscope.core.agui.adapter.strategy; import io.agentscope.core.agui.adapter.AguiAdapterConfig; +import io.agentscope.core.agui.converter.AguiMessageConverter; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.AguiTool; import io.agentscope.core.agui.model.RunAgentInput; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentResultEvent; import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; import io.agentscope.core.model.ChatUsage; import io.agentscope.core.util.JsonException; @@ -32,6 +36,7 @@ import java.util.Objects; import java.util.Set; import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,13 +60,19 @@ public class AguiStreamContext { private final Set endedToolCalls = new LinkedHashSet<>(); private final Set adoptedToolCalls = new LinkedHashSet<>(); private String currentTextMessageId; + private String currentTextReplyId; private String currentReasoningMessageId; + private final Map> textMessageIdsByReply = new LinkedHashMap<>(); + private final Map activeTextMessageIdsByReply = new LinkedHashMap<>(); private final Map toolResultContent = new LinkedHashMap<>(); private final Map pendingInterrupts = new LinkedHashMap<>(); private final Set warnedMissingToolCallIdOperations = new LinkedHashSet<>(); private final TokenUsageAccumulator tokenUsageAccumulator = new TokenUsageAccumulator(); private final Predicate isExternalTool; private final Map startedToolCallNames = new LinkedHashMap<>(); + private final Supplier> authoritativeMessagesSupplier; + private final AguiMessageConverter messageConverter = new AguiMessageConverter(); + private Msg finalResult; public AguiStreamContext(String threadId, String runId, AguiAdapterConfig config) { this(threadId, runId, config, null, null); @@ -89,12 +100,26 @@ public AguiStreamContext( AguiAdapterConfig config, RunAgentInput runInput, Predicate isExternalTool) { + this(threadId, runId, config, runInput, isExternalTool, List::of); + } + + public AguiStreamContext( + String threadId, + String runId, + AguiAdapterConfig config, + RunAgentInput runInput, + Predicate isExternalTool, + Supplier> authoritativeMessagesSupplier) { this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null"); this.runId = Objects.requireNonNull(runId, "runId cannot be null"); this.config = Objects.requireNonNull(config, "config cannot be null"); this.runInput = runInput; this.isExternalTool = isExternalTool != null ? isExternalTool : defaultExternalToolDetector(runInput); + this.authoritativeMessagesSupplier = + Objects.requireNonNull( + authoritativeMessagesSupplier, + "authoritativeMessagesSupplier cannot be null"); } public String getThreadId() { @@ -127,21 +152,29 @@ public void emit(AguiEvent event) { pendingEvents.add(event); } + public void observe(AgentEvent event) { + if (event instanceof AgentResultEvent resultEvent && isBlank(event.getSource())) { + finalResult = resultEvent.getResult(); + } + } + TokenUsageAccumulator getTokenUsageAccumulator() { return tokenUsageAccumulator; } - public void startTextMessage(String messageId) { + public void startTextMessage(String replyId) { + String messageId = resolveTextMessageId(replyId); if (startedTextMessages.add(messageId)) { emit(new AguiEvent.TextMessageStart(threadId, runId, messageId, "assistant")); } + currentTextReplyId = replyId; currentTextMessageId = messageId; } - public void appendTextDelta(String messageId, String delta) { + public void appendTextDelta(String replyId, String delta) { if (delta != null && !delta.isEmpty()) { - startTextMessage(messageId); - emit(new AguiEvent.TextMessageContent(threadId, runId, messageId, delta)); + startTextMessage(replyId); + emit(new AguiEvent.TextMessageContent(threadId, runId, currentTextMessageId, delta)); } } @@ -149,10 +182,18 @@ public void closeActiveTextMessage() { if (currentTextMessageId == null) { return; } - closeTextMessage(currentTextMessageId); + closeResolvedTextMessage(currentTextReplyId, currentTextMessageId); } - public void closeTextMessage(String messageId) { + public void closeTextMessage(String replyId) { + String messageId = + config.isTextOutputDispositionEnabled() + ? activeTextMessageIdsByReply.get(replyId) + : replyId; + closeResolvedTextMessage(replyId, messageId); + } + + private void closeResolvedTextMessage(String replyId, String messageId) { if (messageId == null || !startedTextMessages.contains(messageId) || endedTextMessages.contains(messageId)) { @@ -161,10 +202,43 @@ public void closeTextMessage(String messageId) { endedTextMessages.add(messageId); if (Objects.equals(messageId, currentTextMessageId)) { currentTextMessageId = null; + currentTextReplyId = null; + } + if (config.isTextOutputDispositionEnabled()) { + activeTextMessageIdsByReply.remove(replyId, messageId); } emit(new AguiEvent.TextMessageEnd(threadId, runId, messageId)); } + public List getTextMessageIds(String replyId) { + if (!config.isTextOutputDispositionEnabled()) { + return startedTextMessages.contains(replyId) ? List.of(replyId) : List.of(); + } + return List.copyOf(textMessageIdsByReply.getOrDefault(replyId, List.of())); + } + + public void emitFinalMessagesSnapshot() { + if (finalResult == null) { + return; + } + Map messagesById = new LinkedHashMap<>(); + List authoritativeMessages = authoritativeMessagesSupplier.get(); + if (authoritativeMessages != null) { + for (Msg message : authoritativeMessages) { + if (message != null) { + messagesById.put(message.getId(), message); + } + } + } + messagesById.put(finalResult.getId(), finalResult); + emit( + new AguiEvent.MessagesSnapshot( + threadId, + runId, + messageConverter.toAguiMessageList( + new ArrayList<>(messagesById.values())))); + } + public void startReasoningMessage(String messageId) { if (startedReasoningMessages.add(messageId)) { emit(new AguiEvent.ReasoningMessageStart(threadId, runId, messageId, "reasoning")); @@ -343,6 +417,22 @@ private StringBuilder toolResultBuffer(String toolCallId) { return toolResultContent.computeIfAbsent(toolCallId, ignored -> new StringBuilder()); } + private String resolveTextMessageId(String replyId) { + if (!config.isTextOutputDispositionEnabled()) { + return replyId; + } + return activeTextMessageIdsByReply.computeIfAbsent( + replyId, + key -> { + List messageIds = + textMessageIdsByReply.computeIfAbsent( + key, ignored -> new ArrayList<>()); + String messageId = key + ":text:" + messageIds.size(); + messageIds.add(messageId); + return messageId; + }); + } + private static String normalizeToolCallName(String toolCallName) { return toolCallName != null && !toolCallName.isBlank() ? toolCallName : "unknown"; } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java new file mode 100644 index 0000000000..f0825ba7b1 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java @@ -0,0 +1,69 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agui.adapter.strategy; + +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.TextOutputDisposition; +import io.agentscope.core.event.TextOutputDispositionEvent; +import io.agentscope.core.message.GenerateReason; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** Converts opt-in text output lifecycle signals to AG-UI custom events and final snapshots. */ +public final class TextOutputDispositionConverter implements AgentEventConverter { + + public static final String EVENT_NAME = "agentscope.text_output.disposition"; + + private static final Set FINAL_REASONS = + EnumSet.of( + GenerateReason.MODEL_STOP, + GenerateReason.STRUCTURED_OUTPUT, + GenerateReason.MAX_ITERATIONS); + + @Override + public Set> eventTypes() { + return Set.of(TextOutputDispositionEvent.class); + } + + @Override + public void convert(AgentEvent event, AguiStreamContext context) { + TextOutputDispositionEvent dispositionEvent = (TextOutputDispositionEvent) event; + Map value = new LinkedHashMap<>(); + value.put("replyId", dispositionEvent.getReplyId()); + value.put("messageIds", context.getTextMessageIds(dispositionEvent.getReplyId())); + value.put("disposition", dispositionEvent.getDisposition().name()); + value.put( + "generateReason", + dispositionEvent.getGenerateReason() != null + ? dispositionEvent.getGenerateReason().name() + : null); + context.emit( + new AguiEvent.Custom( + context.getThreadId(), + context.getRunId(), + EVENT_NAME, + Collections.unmodifiableMap(value))); + + if (dispositionEvent.getDisposition() == TextOutputDisposition.TERMINAL + && FINAL_REASONS.contains(dispositionEvent.getGenerateReason())) { + context.emitFinalMessagesSnapshot(); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java index ee1403cf4a..b9716b0985 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAdapterConfigTest.java @@ -47,6 +47,7 @@ void testDefaultConfig() { assertTrue(config.isEmitToolCallArgs()); assertFalse(config.isEmitTokenUsage()); assertFalse(config.isEnableReasoning()); // Default should be false + assertFalse(config.isTextOutputDispositionEnabled()); assertFalse(config.isEmitRunFinishedAfterError()); // Default should be false assertEquals(Duration.ofMinutes(10), config.getRunTimeout()); assertNull(config.getDefaultAgentId()); @@ -64,6 +65,7 @@ void testBuilderWithDefaults() { assertTrue(config.isEmitStateEvents()); assertTrue(config.isEmitToolCallArgs()); assertFalse(config.isEmitTokenUsage()); + assertFalse(config.isTextOutputDispositionEnabled()); assertEquals(Duration.ofMinutes(10), config.getRunTimeout()); assertFalse(config.isBaseEventPropertiesEnricherEnabled()); assertTrue(config.getEventEnrichers().isEmpty()); @@ -122,6 +124,17 @@ void testBuilderEmitTokenUsage() { assertTrue(configEnabled.isEmitTokenUsage()); } + @Test + void testBuilderTextOutputDispositionEnabled() { + AguiAdapterConfig configDisabled = + AguiAdapterConfig.builder().textOutputDispositionEnabled(false).build(); + AguiAdapterConfig configEnabled = + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(); + + assertFalse(configDisabled.isTextOutputDispositionEnabled()); + assertTrue(configEnabled.isTextOutputDispositionEnabled()); + } + @Test void testBuilderRunTimeout() { Duration customTimeout = Duration.ofMinutes(30); @@ -161,6 +174,7 @@ void testBuilderFullConfiguration() { .emitToolCallArgs(false) .emitTokenUsage(true) .enableReasoning(true) + .textOutputDispositionEnabled(true) .emitRunFinishedAfterError(true) .runTimeout(Duration.ofHours(1)) .defaultAgentId("my-agent") @@ -172,6 +186,7 @@ void testBuilderFullConfiguration() { assertFalse(config.isEmitToolCallArgs()); assertTrue(config.isEmitTokenUsage()); assertTrue(config.isEnableReasoning()); + assertTrue(config.isTextOutputDispositionEnabled()); assertTrue(config.isEmitRunFinishedAfterError()); assertEquals(Duration.ofHours(1), config.getRunTimeout()); assertEquals("my-agent", config.getDefaultAgentId()); diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java index 3616d1b321..b5b2f1b941 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java @@ -50,6 +50,7 @@ import io.agentscope.core.event.DataBlockStartEvent; import io.agentscope.core.event.ExternalExecutionResultEvent; import io.agentscope.core.event.ModelCallEndEvent; +import io.agentscope.core.event.ModelCallStartEvent; import io.agentscope.core.event.RequireExternalExecutionEvent; import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.TextBlockDeltaEvent; @@ -70,12 +71,14 @@ 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; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolResultState; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.model.ChatUsage; import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.state.AgentState; import io.agentscope.core.tool.SchemaOnlyTool; import io.agentscope.core.tool.Toolkit; import io.agentscope.harness.agent.HarnessAgent; @@ -86,6 +89,8 @@ import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.ArgumentCaptor; import reactor.core.publisher.Flux; @@ -308,6 +313,216 @@ void testRunUsesHarnessStreamEventsViaReflection() { @Nested class TextAndReasoningConversionTests { + @Test + void testTextOutputDispositionRemainsDisabledWithoutChangingLegacySequenceOrMessageId() { + List events = + runReActEvents( + new AgentStartEvent("thread-v2", "reply-legacy", "react"), + new ModelCallStartEvent("reply-legacy"), + new TextBlockDeltaEvent("reply-legacy", "text-1", "answer"), + new TextBlockEndEvent("reply-legacy", "text-1"), + new AgentResultEvent( + AssistantMessage.builder() + .id("reply-legacy") + .content(TextBlock.builder().text("answer").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build()), + new AgentEndEvent("reply-legacy")); + + assertEquals( + List.of( + AguiEventType.RUN_STARTED, + AguiEventType.TEXT_MESSAGE_START, + AguiEventType.TEXT_MESSAGE_CONTENT, + AguiEventType.TEXT_MESSAGE_END, + AguiEventType.RUN_FINISHED), + types(events)); + assertEquals( + List.of("reply-legacy", "reply-legacy", "reply-legacy"), + events.stream() + .filter( + event -> + event instanceof AguiEvent.TextMessageStart + || event instanceof AguiEvent.TextMessageContent + || event instanceof AguiEvent.TextMessageEnd) + .map( + event -> { + if (event instanceof AguiEvent.TextMessageStart start) { + return start.messageId(); + } + if (event instanceof AguiEvent.TextMessageContent content) { + return content.messageId(); + } + return ((AguiEvent.TextMessageEnd) event).messageId(); + }) + .toList()); + assertFalse(events.stream().anyMatch(AguiEvent.Custom.class::isInstance)); + assertFalse(events.stream().anyMatch(AguiEvent.MessagesSnapshot.class::isInstance)); + } + + @Test + void testEnabledDispositionUsesSegmentIdsAndEmitsOneCustomEventWithoutReasoning() { + AguiAdapterConfig config = + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(); + List events = + runReActEvents( + config, + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "text-1", "first"), + new TextBlockEndEvent("reply-1", "text-1"), + new TextBlockDeltaEvent("reply-1", "text-2", "second"), + new TextBlockEndEvent("reply-1", "text-2"), + new ModelCallStartEvent("reply-2")); + + assertEquals( + List.of( + "reply-1:text:0", + "reply-1:text:0", + "reply-1:text:0", + "reply-1:text:1", + "reply-1:text:1", + "reply-1:text:1"), + events.stream() + .filter( + event -> + event instanceof AguiEvent.TextMessageStart + || event instanceof AguiEvent.TextMessageContent + || event instanceof AguiEvent.TextMessageEnd) + .map( + event -> { + if (event instanceof AguiEvent.TextMessageStart start) { + return start.messageId(); + } + if (event instanceof AguiEvent.TextMessageContent content) { + return content.messageId(); + } + return ((AguiEvent.TextMessageEnd) event).messageId(); + }) + .toList()); + AguiEvent.Custom disposition = + events.stream() + .filter(AguiEvent.Custom.class::isInstance) + .map(AguiEvent.Custom.class::cast) + .findFirst() + .orElseThrow(); + assertEquals("agentscope.text_output.disposition", disposition.name()); + assertEquals( + Map.of( + "replyId", + "reply-1", + "messageIds", + List.of("reply-1:text:0", "reply-1:text:1"), + "disposition", + "INTERMEDIATE"), + customValue(disposition).entrySet().stream() + .filter(entry -> entry.getValue() != null) + .collect( + java.util.stream.Collectors.toMap( + Map.Entry::getKey, Map.Entry::getValue))); + assertTrue(customValue(disposition).containsKey("generateReason")); + assertNull(customValue(disposition).get("generateReason")); + assertFalse( + events.stream() + .anyMatch(event -> event.getType().name().startsWith("REASONING"))); + } + + @ParameterizedTest + @EnumSource( + value = GenerateReason.class, + names = {"MODEL_STOP", "STRUCTURED_OUTPUT", "MAX_ITERATIONS"}) + void testEnabledDispositionEmitsMessagesSnapshotForAllowedFinalReasons( + GenerateReason generateReason) { + List events = runTerminalDisposition(generateReason, null); + + assertEquals( + 1, + events.stream().filter(AguiEvent.MessagesSnapshot.class::isInstance).count()); + assertTrue( + events.indexOf( + events.stream() + .filter(AguiEvent.MessagesSnapshot.class::isInstance) + .findFirst() + .orElseThrow()) + < events.indexOf( + events.stream() + .filter(AguiEvent.RunFinished.class::isInstance) + .findFirst() + .orElseThrow())); + } + + @ParameterizedTest + @EnumSource( + value = GenerateReason.class, + mode = EnumSource.Mode.EXCLUDE, + names = {"MODEL_STOP", "STRUCTURED_OUTPUT", "MAX_ITERATIONS"}) + void testEnabledDispositionDoesNotEmitMessagesSnapshotForNonFinalReasons( + GenerateReason generateReason) { + List events = runTerminalDisposition(generateReason, null); + + assertFalse(events.stream().anyMatch(AguiEvent.MessagesSnapshot.class::isInstance)); + } + + @Test + void testFinalSnapshotUsesSessionMessagesAndResultDeduplicatedById() { + Msg sessionUser = + Msg.builder() + .id("session-user") + .role(MsgRole.USER) + .textContent("session question") + .build(); + Msg staleResult = + AssistantMessage.builder() + .id("reply-final") + .content(TextBlock.builder().text("stale result").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + AgentState state = + AgentState.builder().context(List.of(sessionUser, staleResult)).build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + Msg finalResult = + AssistantMessage.builder() + .id("reply-final") + .content( + List.of( + TextBlock.builder().text("canonical result").build(), + ToolUseBlock.builder() + .id("tool-final") + .name("lookup") + .input(Map.of("q", "answer")) + .build())) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + List events = runTerminalDisposition(callerContext, finalResult); + + int snapshotIndex = + events.indexOf( + events.stream() + .filter(AguiEvent.MessagesSnapshot.class::isInstance) + .findFirst() + .orElseThrow()); + int finishedIndex = + events.indexOf( + events.stream() + .filter(AguiEvent.RunFinished.class::isInstance) + .findFirst() + .orElseThrow()); + AguiEvent.MessagesSnapshot snapshot = + assertInstanceOf(AguiEvent.MessagesSnapshot.class, events.get(snapshotIndex)); + + assertEquals(finishedIndex - 1, snapshotIndex); + assertEquals( + List.of("session-user", "reply-final"), + snapshot.messages().stream().map(AguiMessage::getId).toList()); + AguiMessage resultMessage = snapshot.messages().get(1); + assertEquals("canonical result", resultMessage.getTextContent()); + assertEquals(1, resultMessage.getToolCalls().size()); + assertFalse( + snapshot.messages().stream() + .map(AguiMessage::getId) + .anyMatch(id -> id.contains(":text:"))); + } + @Test void testTextBlockEventsConvertToAguiTextMessageEvents() { List events = @@ -611,6 +826,38 @@ void testStateIsIsolatedAcrossMultipleSubscriptions() { 1, secondEvents.stream().filter(AguiEvent.ToolCallEnd.class::isInstance).count()); } + + private List runTerminalDisposition( + GenerateReason generateReason, RuntimeContext callerContext) { + Msg result = + AssistantMessage.builder() + .id("reply-final") + .content(TextBlock.builder().text("canonical result").build()) + .generateReason(generateReason) + .build(); + return runTerminalDisposition(callerContext, result); + } + + private List runTerminalDisposition(RuntimeContext callerContext, Msg result) { + ReActAgent agent = mock(ReActAgent.class); + when(agent.streamEvents(anyList(), any(RuntimeContext.class))) + .thenReturn( + Flux.just( + new AgentStartEvent("thread-v2", "reply-final", "react"), + new ModelCallStartEvent("reply-final"), + new TextBlockDeltaEvent( + "reply-final", "text-live", "streamed preview"), + new TextBlockEndEvent("reply-final", "text-live"), + new AgentResultEvent(result), + new AgentEndEvent("reply-final"))); + AguiAdapterConfig config = + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(); + + return new AguiAgentAdapter(agent, config) + .run(input(), callerContext) + .collectList() + .block(); + } } @Nested From f7039183585f08484e768e545ae93d68fcf1e504 Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:57:52 +0800 Subject: [PATCH 10/22] =?UTF-8?q?fix(agui):=20=E6=A0=A1=E5=87=86=E6=9C=80?= =?UTF-8?q?=E7=BB=88=E6=B6=88=E6=81=AF=E5=BF=AB=E7=85=A7=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E4=B8=8E=E5=A4=9A=E6=A8=A1=E6=80=81=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 3ab4abe76c169b32cb37abf1d5f05f4cf900c707) --- .../core/agui/adapter/AguiAgentAdapter.java | 2 +- .../AgentLifecycleEventConverter.java | 1 + .../adapter/strategy/AguiStreamContext.java | 48 +++- .../TextOutputDispositionConverter.java | 14 -- .../agui/converter/AguiMessageConverter.java | 60 +++-- .../agui/adapter/AguiAgentAdapterV2Test.java | 238 +++++++++++++++++- 6 files changed, 321 insertions(+), 42 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java index 7ca238be74..88446d47e8 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java @@ -278,7 +278,7 @@ private Flux applyTextOutputDisposition(Flux events) { private List authoritativeMessages(RuntimeContext runtimeContext) { var state = RuntimeContext.resolveAgentState(runtimeContext, agent); - return state != null ? state.getContext() : List.of(); + return state != null ? state.getContext() : null; } private record AgentStream(Flux events, Supplier> finish) {} diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java index 5201ed6e67..341ff92293 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java @@ -81,6 +81,7 @@ public void convert(AgentEvent event, AguiStreamContext context) { for (AguiEvent pendingEvent : context.finishPendingEvents()) { context.emit(pendingEvent); } + context.emitFinalMessagesSnapshot((AgentEndEvent) event); List interrupts = context.getPendingInterrupts(); AguiEvent.RunFinishedOutcome outcome = interrupts.isEmpty() diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java index 91349c0afe..e55e7b7391 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java @@ -18,17 +18,21 @@ import io.agentscope.core.agui.adapter.AguiAdapterConfig; import io.agentscope.core.agui.converter.AguiMessageConverter; import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.AguiTool; import io.agentscope.core.agui.model.RunAgentInput; +import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; import io.agentscope.core.event.AgentResultEvent; import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.GenerateReason; import io.agentscope.core.message.Msg; import io.agentscope.core.message.TextBlock; import io.agentscope.core.model.ChatUsage; import io.agentscope.core.util.JsonException; import io.agentscope.core.util.JsonUtils; import java.util.ArrayList; +import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -37,6 +41,7 @@ import java.util.Set; import java.util.function.Predicate; import java.util.function.Supplier; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,6 +49,12 @@ public class AguiStreamContext { private static final Logger logger = LoggerFactory.getLogger(AguiStreamContext.class); + private static final Set FINAL_SNAPSHOT_REASONS = + EnumSet.of( + GenerateReason.MODEL_STOP, + GenerateReason.STRUCTURED_OUTPUT, + GenerateReason.MAX_ITERATIONS); + private static final Pattern TEXT_SEGMENT_ID = Pattern.compile("^.+:text:\\d+$"); private final String threadId; private final String runId; @@ -217,26 +228,39 @@ public List getTextMessageIds(String replyId) { return List.copyOf(textMessageIdsByReply.getOrDefault(replyId, List.of())); } - public void emitFinalMessagesSnapshot() { - if (finalResult == null) { + public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) { + if (!config.isTextOutputDispositionEnabled() + || !isBlank(endEvent.getSource()) + || finalResult == null + || !FINAL_SNAPSHOT_REASONS.contains(finalResult.getGenerateReason()) + || !pendingInterrupts.isEmpty()) { return; } - Map messagesById = new LinkedHashMap<>(); + Map messagesById = new LinkedHashMap<>(); List authoritativeMessages = authoritativeMessagesSupplier.get(); - if (authoritativeMessages != null) { + boolean hasAuthoritativeMessages = + authoritativeMessages != null && !authoritativeMessages.isEmpty(); + if (hasAuthoritativeMessages) { for (Msg message : authoritativeMessages) { - if (message != null) { + if (message != null && !isTextSegmentId(message.getId())) { + messagesById.put(message.getId(), messageConverter.toAguiMessage(message)); + } + } + } + if (runInput != null) { + for (AguiMessage message : runInput.getMessages()) { + if (message != null + && !isTextSegmentId(message.getId()) + && (!hasAuthoritativeMessages + || messagesById.containsKey(message.getId()))) { messagesById.put(message.getId(), message); } } } - messagesById.put(finalResult.getId(), finalResult); + messagesById.put(finalResult.getId(), messageConverter.toAguiMessage(finalResult)); emit( new AguiEvent.MessagesSnapshot( - threadId, - runId, - messageConverter.toAguiMessageList( - new ArrayList<>(messagesById.values())))); + threadId, runId, new ArrayList<>(messagesById.values()))); } public void startReasoningMessage(String messageId) { @@ -433,6 +457,10 @@ private String resolveTextMessageId(String replyId) { }); } + private static boolean isTextSegmentId(String messageId) { + return messageId != null && TEXT_SEGMENT_ID.matcher(messageId).matches(); + } + private static String normalizeToolCallName(String toolCallName) { return toolCallName != null && !toolCallName.isBlank() ? toolCallName : "unknown"; } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java index f0825ba7b1..b2e2e0bcf4 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java @@ -17,11 +17,8 @@ import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.event.AgentEvent; -import io.agentscope.core.event.TextOutputDisposition; import io.agentscope.core.event.TextOutputDispositionEvent; -import io.agentscope.core.message.GenerateReason; import java.util.Collections; -import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -31,12 +28,6 @@ public final class TextOutputDispositionConverter implements AgentEventConverter public static final String EVENT_NAME = "agentscope.text_output.disposition"; - private static final Set FINAL_REASONS = - EnumSet.of( - GenerateReason.MODEL_STOP, - GenerateReason.STRUCTURED_OUTPUT, - GenerateReason.MAX_ITERATIONS); - @Override public Set> eventTypes() { return Set.of(TextOutputDispositionEvent.class); @@ -60,10 +51,5 @@ public void convert(AgentEvent event, AguiStreamContext context) { context.getRunId(), EVENT_NAME, Collections.unmodifiableMap(value))); - - if (dispositionEvent.getDisposition() == TextOutputDisposition.TERMINAL - && FINAL_REASONS.contains(dispositionEvent.getGenerateReason())) { - context.emitFinalMessagesSnapshot(); - } } } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java index a56ca108e7..9ff2e2437d 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java @@ -129,40 +129,60 @@ public Msg toMsg(AguiMessage aguiMessage) { */ public AguiMessage toAguiMessage(Msg msg) { String role = convertRole(msg.getRole()); - StringBuilder content = new StringBuilder(); + List contentParts = new ArrayList<>(); List toolCalls = new ArrayList<>(); String toolCallId = null; for (ContentBlock block : msg.getContent()) { - if (block instanceof TextBlock tb) { - if (content.length() > 0) { - content.append("\n"); - } - content.append(tb.getText()); - } else if (block instanceof ToolUseBlock tub) { + if (block instanceof ToolUseBlock tub) { toolCalls.add(toAguiToolCall(tub)); } else if (block instanceof ToolResultBlock trb) { toolCallId = trb.getId(); - // Extract text content from tool result for (ContentBlock output : trb.getOutput()) { - if (output instanceof TextBlock tb) { - if (content.length() > 0) { - content.append("\n"); - } - content.append(tb.getText()); - } + addAguiContentPart(contentParts, output); } + } else { + addAguiContentPart(contentParts, block); } } return new AguiMessage( msg.getId(), role, - content.length() > 0 ? new MessageContent.Text(content.toString()) : null, + toMessageContent(contentParts), toolCalls.isEmpty() ? null : toolCalls, toolCallId); } + private MessageContent toMessageContent(List contentParts) { + if (contentParts.isEmpty()) { + return null; + } + if (contentParts.stream().allMatch(TextInputContent.class::isInstance)) { + String text = + contentParts.stream() + .map(TextInputContent.class::cast) + .map(TextInputContent::text) + .collect(Collectors.joining("\n")); + return text.isEmpty() ? null : new MessageContent.Text(text); + } + return new MessageContent.Blocks(contentParts); + } + + private void addAguiContentPart(List contentParts, ContentBlock block) { + if (block instanceof TextBlock text) { + if (text.getText() != null && !text.getText().isEmpty()) { + contentParts.add(new TextInputContent(text.getText())); + } + } else if (block instanceof ImageBlock image) { + contentParts.add(new ImageInputContent(toInputContentSource(image.getSource()), null)); + } else if (block instanceof AudioBlock audio) { + contentParts.add(new AudioInputContent(toInputContentSource(audio.getSource()), null)); + } else if (block instanceof VideoBlock video) { + contentParts.add(new VideoInputContent(toInputContentSource(video.getSource()), null)); + } + } + /** * Convert a list of AG-UI messages to AgentScope messages. * @@ -359,6 +379,16 @@ private Source toSource(InputContentSource inputSource) { throw new IllegalStateException("Unhandled InputContentSource type: " + inputSource); } + private InputContentSource toInputContentSource(Source source) { + if (source instanceof URLSource url) { + return new InputContentUrlSource(url.getUrl(), url.getMimeType()); + } + if (source instanceof Base64Source data) { + return new InputContentDataSource(data.getData(), data.getMediaType()); + } + throw new IllegalStateException("Unhandled Source type: " + source); + } + /** * Convert an AG-UI tool call to an AgentScope ToolUseBlock. * diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java index b5b2f1b941..15cb555b73 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java @@ -39,8 +39,15 @@ import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.AguiResume; import io.agentscope.core.agui.model.AguiTool; +import io.agentscope.core.agui.model.AudioInputContent; +import io.agentscope.core.agui.model.ImageInputContent; +import io.agentscope.core.agui.model.InputContentDataSource; +import io.agentscope.core.agui.model.InputContentUrlSource; +import io.agentscope.core.agui.model.MessageContent; import io.agentscope.core.agui.model.RunAgentInput; +import io.agentscope.core.agui.model.TextInputContent; import io.agentscope.core.agui.model.ToolMergeMode; +import io.agentscope.core.agui.model.VideoInputContent; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; import io.agentscope.core.event.AgentResultEvent; @@ -56,6 +63,7 @@ import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ThinkingBlockDeltaEvent; import io.agentscope.core.event.ThinkingBlockEndEvent; import io.agentscope.core.event.ThinkingBlockStartEvent; @@ -68,14 +76,19 @@ import io.agentscope.core.event.ToolResultTextDeltaEvent; import io.agentscope.core.event.UserConfirmResultEvent; import io.agentscope.core.message.AssistantMessage; +import io.agentscope.core.message.AudioBlock; +import io.agentscope.core.message.Base64Source; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.ImageBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolResultState; import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.message.URLSource; +import io.agentscope.core.message.VideoBlock; import io.agentscope.core.model.ChatUsage; import io.agentscope.core.model.ToolSchema; import io.agentscope.core.state.AgentState; @@ -450,6 +463,85 @@ void testEnabledDispositionEmitsMessagesSnapshotForAllowedFinalReasons( .orElseThrow())); } + @ParameterizedTest + @EnumSource( + value = GenerateReason.class, + names = {"MODEL_STOP", "STRUCTURED_OUTPUT", "MAX_ITERATIONS"}) + void testEnabledDispositionEmitsSnapshotForResultOnlyFinalReasons( + GenerateReason generateReason) { + Msg result = + AssistantMessage.builder() + .id("reply-result-only") + .content(TextBlock.builder().text("authoritative result").build()) + .generateReason(generateReason) + .build(); + + List events = + runReActEvents( + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(), + new AgentStartEvent("thread-v2", "reply-result-only", "react"), + new AgentResultEvent(result), + new AgentEndEvent("reply-result-only")); + + assertEquals( + List.of( + AguiEventType.RUN_STARTED, + AguiEventType.MESSAGES_SNAPSHOT, + AguiEventType.RUN_FINISHED), + types(events)); + } + + @Test + void testCustomDispositionConverterDoesNotDisableFinalSnapshot() { + AgentEventConverter customDispositionConverter = + new AgentEventConverter() { + @Override + public Set> eventTypes() { + return Set.of(TextOutputDispositionEvent.class); + } + + @Override + public void convert(AgentEvent event, AguiStreamContext context) { + context.emit( + new AguiEvent.Custom( + context.getThreadId(), + context.getRunId(), + "custom.disposition", + Map.of())); + } + }; + AguiAdapterConfig config = + AguiAdapterConfig.builder() + .textOutputDispositionEnabled(true) + .addEventConverter(customDispositionConverter) + .build(); + Msg result = + AssistantMessage.builder() + .id("reply-custom") + .content(TextBlock.builder().text("answer").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + List events = + runReActEvents( + config, + new AgentStartEvent("thread-v2", "reply-custom", "react"), + new ModelCallStartEvent("reply-custom"), + new TextBlockDeltaEvent("reply-custom", "text-1", "answer"), + new TextBlockEndEvent("reply-custom", "text-1"), + new AgentResultEvent(result), + new AgentEndEvent("reply-custom")); + + assertTrue( + events.stream() + .filter(AguiEvent.Custom.class::isInstance) + .map(AguiEvent.Custom.class::cast) + .anyMatch(event -> "custom.disposition".equals(event.name()))); + assertEquals( + 1, + events.stream().filter(AguiEvent.MessagesSnapshot.class::isInstance).count()); + } + @ParameterizedTest @EnumSource( value = GenerateReason.class, @@ -523,6 +615,131 @@ void testFinalSnapshotUsesSessionMessagesAndResultDeduplicatedById() { .anyMatch(id -> id.contains(":text:"))); } + @Test + void testFinalSnapshotFallsBackToOriginalInputWithoutAgentState() { + AguiMessage inputMessage = + AguiMessage.userMessage( + "input-media", + List.of( + new TextInputContent("describe this"), + new ImageInputContent( + new InputContentUrlSource( + "https://example.test/input.png", "image/png"), + Map.of("detail", "high")))); + RunAgentInput runInput = inputBuilder().messages(List.of(inputMessage)).build(); + Msg result = + AssistantMessage.builder() + .id("reply-final") + .content(TextBlock.builder().text("description").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + List events = runTerminalDisposition(runInput, null, result); + + AguiEvent.MessagesSnapshot snapshot = snapshot(events); + assertEquals(List.of("input-media", "reply-final"), messageIds(snapshot)); + assertEquals(inputMessage, snapshot.messages().get(0)); + } + + @Test + void testFinalSnapshotExcludesOnlyReservedTextSegmentIds() { + Msg user = Msg.builder().id("session-user").role(MsgRole.USER).textContent("q").build(); + Msg liveSegment = + AssistantMessage.builder() + .id("reply-preview:text:7") + .content(TextBlock.builder().text("preview").build()) + .build(); + Msg ordinaryColonId = + AssistantMessage.builder() + .id("reply-preview:text:final") + .content(TextBlock.builder().text("kept").build()) + .build(); + AgentState state = + AgentState.builder() + .context(List.of(user, liveSegment, ordinaryColonId)) + .build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + + List events = + runTerminalDisposition(GenerateReason.MODEL_STOP, callerContext); + + assertEquals( + List.of("session-user", "reply-preview:text:final", "reply-final"), + messageIds(snapshot(events))); + } + + @Test + void testFinalSnapshotPreservesMultimodalStateAndResultContent() { + Msg history = + Msg.builder() + .id("history-media") + .role(MsgRole.USER) + .content( + List.of( + TextBlock.builder().text("history text").build(), + ImageBlock.builder() + .source( + new URLSource( + "https://example.test/history.png", + "image/png")) + .build(), + AudioBlock.builder() + .source( + new Base64Source( + "audio/wav", "aGlzdG9yeQ==")) + .build())) + .build(); + AgentState state = AgentState.builder().context(List.of(history)).build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + Msg result = + AssistantMessage.builder() + .id("reply-media") + .content( + List.of( + TextBlock.builder().text("result text").build(), + VideoBlock.builder() + .source( + new URLSource( + "https://example.test/result.mp4", + "video/mp4")) + .build())) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + List events = runTerminalDisposition(callerContext, result); + + AguiEvent.MessagesSnapshot snapshot = snapshot(events); + MessageContent.Blocks historyContent = + assertInstanceOf( + MessageContent.Blocks.class, snapshot.messages().get(0).getContent()); + assertEquals( + List.of( + TextInputContent.class, + ImageInputContent.class, + AudioInputContent.class), + historyContent.parts().stream().map(Object::getClass).toList()); + ImageInputContent image = + assertInstanceOf(ImageInputContent.class, historyContent.parts().get(1)); + assertEquals( + new InputContentUrlSource("https://example.test/history.png", "image/png"), + image.source()); + AudioInputContent audio = + assertInstanceOf(AudioInputContent.class, historyContent.parts().get(2)); + assertEquals(new InputContentDataSource("aGlzdG9yeQ==", "audio/wav"), audio.source()); + + MessageContent.Blocks resultContent = + assertInstanceOf( + MessageContent.Blocks.class, snapshot.messages().get(1).getContent()); + assertEquals( + List.of(TextInputContent.class, VideoInputContent.class), + resultContent.parts().stream().map(Object::getClass).toList()); + VideoInputContent video = + assertInstanceOf(VideoInputContent.class, resultContent.parts().get(1)); + assertEquals( + new InputContentUrlSource("https://example.test/result.mp4", "video/mp4"), + video.source()); + } + @Test void testTextBlockEventsConvertToAguiTextMessageEvents() { List events = @@ -835,10 +1052,15 @@ private List runTerminalDisposition( .content(TextBlock.builder().text("canonical result").build()) .generateReason(generateReason) .build(); - return runTerminalDisposition(callerContext, result); + return runTerminalDisposition(input(), callerContext, result); } private List runTerminalDisposition(RuntimeContext callerContext, Msg result) { + return runTerminalDisposition(input(), callerContext, result); + } + + private List runTerminalDisposition( + RunAgentInput runInput, RuntimeContext callerContext, Msg result) { ReActAgent agent = mock(ReActAgent.class); when(agent.streamEvents(anyList(), any(RuntimeContext.class))) .thenReturn( @@ -854,10 +1076,22 @@ private List runTerminalDisposition(RuntimeContext callerContext, Msg AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(); return new AguiAgentAdapter(agent, config) - .run(input(), callerContext) + .run(runInput, callerContext) .collectList() .block(); } + + private AguiEvent.MessagesSnapshot snapshot(List events) { + return events.stream() + .filter(AguiEvent.MessagesSnapshot.class::isInstance) + .map(AguiEvent.MessagesSnapshot.class::cast) + .findFirst() + .orElseThrow(); + } + + private List messageIds(AguiEvent.MessagesSnapshot snapshot) { + return snapshot.messages().stream().map(AguiMessage::getId).toList(); + } } @Nested From 95f2c7284388387746391bcaa9b9ef124da0fd9a Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:28:05 +0800 Subject: [PATCH 11/22] =?UTF-8?q?docs(streaming):=20=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=A4=84=E7=BD=AE=E4=B8=8E=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E6=A0=A1=E5=87=86=E7=94=A8=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 50ed7659eff2b46592735270f611444cfe41623b) --- .../task-9-report.md | 120 ++++++++++++++++++ .../streaming/AgentEventStreamExample.java | 101 +++------------ .../agentscope-extensions-agui/README.md | 37 ++++++ .../subagent/protocol/RemoteEventCodec.java | 6 +- .../subagent/protocol/RemoteEventType.java | 5 +- .../subagent/protocol/RemoteStreamDetail.java | 7 +- .../docs/managed_agents/guide/07-events.md | 13 ++ 7 files changed, 203 insertions(+), 86 deletions(-) create mode 100644 .superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md create mode 100644 agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md diff --git a/.superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md b/.superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md new file mode 100644 index 0000000000..0034877771 --- /dev/null +++ b/.superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md @@ -0,0 +1,120 @@ +# Task 9 交付报告:文档、兼容性与全量验证 + +日期:2026-09-03 + +分支:`codex/stream-events-text-disposition` + +工作树:`D:\ai-code\agentscope-java\.worktrees\stream-events-text-disposition` + +## 结论 + +Task 9 的文档与 Javadoc 已完成,并验证了 Core 显式启用方式、`TERMINAL` 与最终答案的区别、Remote 字符串 payload 兼容语义、AG-UI/Web 启用方式及默认关闭兼容性。 + +必需 Maven 命令在当前 Windows 环境中会被两个无法创建符号链接的 Core 用例提前阻断;排除这两个环境用例后,Agent Protocol、AG-UI 与 Data Plane 的目标 reactor 均成功。Harness 的原始补充运行还暴露了三个依赖 Unix `sh` 的环境用例;进一步排除该类后,Harness 及下游目标模块均通过。前端测试通过;完整前端 build 仍被 HEAD 既有缺失的两个 `src/features/build/**` 页面阻断,Task 8 的三个目标文件通过独立严格 TypeScript 检查。 + +## 实现内容 + +### Core 示例 + +- 更新 `AgentEventStreamExample`,使用: + + ```java + AgentEventStreams.withTextOutputDisposition(agent.streamEvents(input)) + ``` + +- 输出 `replyId -> disposition`。 +- 明确说明 `TERMINAL` 只关闭流式文本生命周期,`AgentResultEvent` 仍是权威调用结果。 +- 保留 opt-in 语义,没有修改 `ReActAgent#streamEvents()` 的默认序列。 + +### Managed Web 文档 + +- 增加 `event_update` SSE 事件说明。 +- 说明 Managed Web 服务端已内部启用文本处置派生,客户端通过 `event_deltas=agent.message` 订阅。 +- 说明 `INTERMEDIATE`、`TERMINAL != final answer`、权威空结果清除预览、权威结果校准及不落库语义。 + +### AG-UI 文档 + +- 新增模块 README,记录 `.textOutputDispositionEnabled(true)` 的显式启用方式。 +- 明确默认值为 `false`,未启用时保持旧 message ID 与事件序列。 +- 记录 `agentscope.text_output.disposition` CUSTOM 事件及标准 `MESSAGES_SNAPSHOT` 校准事件。 +- 明确 `TERMINAL` 不代表最终答案。 + +### Remote Javadoc + +- 修正 `RemoteEventCodec`、`RemoteEventType`、`RemoteStreamDetail` 的过时说明。 +- 明确 `detail=full` 会包含 `TEXT_OUTPUT_DISPOSITION` 与 `AGENT_RESULT` 两种 `AGENT_EVENT` subtype;其余 passthrough subtype 仍仅在 `verbose` 下可见。 +- 明确 payload 仍是 JSON `String`,旧客户端可忽略未知 passthrough subtype。 + +## 路径差异 + +brief 中 Remote 文件路径指向 Agent Protocol 扩展模块,但当前仓库实际实现位于: + +`agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/` + +因此本任务修改了 Harness 中的真实生产类。另一个差异是 AG-UI 模块在 HEAD 及历史中都没有 README;本任务按 brief 指定位置新增该文件,而不是覆盖既有文档。 + +## 验证记录 + +所有 Maven 成功复跑均临时使用 `C:\Program Files\Java\jdk-17`;未修改仓库或机器的持久配置。默认 `JAVA_HOME` 是 JDK 21,不满足项目 Maven Enforcer 的 JDK 17 要求。 + +| 命令 | 退出码 | 结果 | +| --- | ---: | --- | +| `mvn spotless:check -DskipTests` | 0 | 91 个 reactor 模块 SUCCESS,无需执行 `spotless:apply` | +| `mvn -pl agentscope-core test -DskipITs -Dtest='!DangerousPathBypassTest'`(默认 JDK 21 首次运行) | 1 | Enforcer 在测试前拒绝 JDK 21;Tests run: 0 | +| 同一 Core 命令,临时切换 JDK 17 | 0 | Tests run: 2390,Failures: 0,Errors: 0,Skipped: 8 | +| `mvn -pl agentscope-harness -am test -DskipITs` | 1 | Core:2399,Failures: 0,Errors: 2,Skipped: 8;两个 `DangerousPathBypassTest` symlink 用例因 Windows “客户端没有所需的特权”失败,Harness 被跳过 | +| `mvn -pl agentscope-harness -am test -DskipITs -Dtest='!DangerousPathBypassTest'` | 1 | Core 通过;Harness:970,Failures: 0,Errors: 3,Skipped: 7;三个 `DockerSandboxCommandTest` 因系统找不到 `sh` 失败 | +| `mvn -pl agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol -am test -DskipITs` | 1 | Core:2399,Failures: 0,Errors: 2,Skipped: 8;同一 symlink 环境错误,下游被跳过 | +| 同一 Agent Protocol 命令,增加 `-Dtest='!DangerousPathBypassTest,!DockerSandboxCommandTest'` | 0 | Core:2390/0/0/8;Harness:964/0/0/6;Agent Protocol:32/0/0/0;reactor 全部 SUCCESS | +| `mvn -pl agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui -am test -DskipITs` | 1 | Core:2399,Failures: 0,Errors: 2,Skipped: 8;同一 symlink 环境错误,AG-UI 被跳过 | +| 同一 AG-UI 命令,增加 `-Dtest='!DangerousPathBypassTest,!DockerSandboxCommandTest'` | 0 | Core:2390/0/0/8;AG-UI:528/0/0/0;reactor 全部 SUCCESS | +| `mvn -pl agentscope-service/service-dataplane -am test -DskipITs` | 1 | Core:2399,Failures: 0,Errors: 2,Skipped: 8;同一 symlink 环境错误,Data Plane 被跳过 | +| 同一 Data Plane 命令,增加 `-Dtest='!DangerousPathBypassTest,!DockerSandboxCommandTest'` | 0 | 15 个 reactor 模块 SUCCESS;Core:2390/0/0/8;Harness:964/0/0/6;Service Common:21/0/0/0;Data Plane:32/0/0/0 | +| `npm test -- --run` | 0 | 1 个测试文件通过,6/6 tests passed;npm 对多余 `--run` 给出未来版本配置警告,实际脚本为 `vitest run` | +| `npm run build` | 1 | `tsc --noEmit` 被 HEAD 既有缺失页面阻断:`DeploymentsPage` 与 `AgentsHubPage` 的 `src/features/build/**` 模块不存在 | +| 首次直接拼接 TypeScript CLI 参数的目标文件试跑 | 1 | PowerShell/TypeScript CLI 对 `--lib`、`--paths` 参数解析失败;属于验证命令写法问题,不是源文件诊断 | +| 临时 `tsconfig.task9.json` + `npx tsc --noEmit -p tsconfig.task9.json` | 0 | Task 8 目标文件 `ChatPanel.tsx`、`ChatPanel.test.tsx`、`MessageBlock.tsx` 严格类型检查通过;临时配置随后删除 | +| `mvn -pl agentscope-examples/documentation -am -DskipTests compile` | 0 | 27 个 reactor 模块 SUCCESS,Documentation 模块 50 个源文件编译成功 | +| `git diff --check`(自审前) | 0 | 无空白错误 | + +### 环境失败明细 + +Windows 符号链接权限: + +- `DangerousPathBypassTest.symlinkToDotEnvIsDetected` +- `DangerousPathBypassTest.symlinkToSshIsDetected` + +缺少 Unix `sh`: + +- `DockerSandboxCommandTest.execProcessDestroysTheHostProcessOnTimeout` +- `DockerSandboxCommandTest.execProcessDestroysTheHostProcessWhenWritingFails` +- `DockerSandboxCommandTest.execProcessDestroysTheHostProcessWhenInterrupted` + +这些失败都发生在环境依赖处,没有观察到本任务修改引发的断言失败。 + +### 前端基线缺失明细 + +`npm run build` 的精确 TypeScript 错误: + +- `src/main.tsx(54,29): TS2307`:缺少 `./features/build/deployments/DeploymentsPage` +- `src/pages/AgentsHubPage.tsx(17,25): TS2307`:缺少 `../features/build/agents/AgentsHubPage` + +按 brief 要求未创建这些无关页面。 + +## 默认兼容性复核 + +- Core:`AgentStreamingTest.testStreamEventCount` 在 Core 回归中通过;示例通过 wrapper 显式 opt-in,未修改原始 `ReActAgent#streamEvents()`。 +- AG-UI:`AguiAdapterConfigTest.testDefaultConfig` 与 `testBuilderWithDefaults` 断言默认关闭;`AguiAgentAdapterV2Test.testTextOutputDispositionRemainsDisabledWithoutChangingLegacySequenceOrMessageId` 覆盖未启用时旧序列和 message ID,AG-UI 全模块 528 个测试通过。 +- Remote:`RemoteAgentEvent.payload` 类型仍为 `String`,DTO 保留 `@JsonIgnoreProperties(ignoreUnknown = true)`;`RemoteEventCodecTest.roundTripTextOutputDispositionAsAgentEventPayload` 与 `RemoteEventCodecPassthroughTest.payloadDecodesEvenWhenTheWireTypeIsUnknownToThisClient` 在 Harness 补充回归中通过。 +- Final answer filter:`FinalAnswerFilterMiddlewareTest` 的 `finalRoundEmitsBufferedTextBeforeModelCallEnd`、`intermediateRoundSuppressesTextWhenToolCallIsObserved`、`nonTextEventsAreForwarded`、`stateIsolatedAcrossSubscriptions` 均在 Core 回归中通过。 + +## 自审 + +- brief Step 1:示例与协议/服务文档已覆盖要求。 +- brief Step 2:Spotless 全 reactor 通过。 +- brief Step 3:五条必需 Maven 命令均已原样运行;环境阻断均精确记录,并用补充命令验证下游目标模块。 +- brief Step 4:前端测试通过;build 基线缺失精确记录;目标文件 TypeScript 检查通过。 +- brief Step 5:四组默认兼容性均有测试或代码证据。 +- brief Step 6:将使用指定提交信息 `docs(streaming): 说明文本处置与结果校准用法` 提交,仅保留本任务文件与本报告。 + +未发现需要新增生产代码、修改默认行为或扩大文档范围的问题。 diff --git a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java index 0949463d9c..f8b8db8fa8 100644 --- a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java +++ b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java @@ -16,15 +16,9 @@ package io.agentscope.examples.documentation2.streaming; import io.agentscope.core.ReActAgent; -import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; -import io.agentscope.core.event.AgentStartEvent; -import io.agentscope.core.event.ModelCallEndEvent; -import io.agentscope.core.event.ModelCallStartEvent; -import io.agentscope.core.event.TextBlockDeltaEvent; -import io.agentscope.core.event.ToolCallEndEvent; -import io.agentscope.core.event.ToolCallStartEvent; -import io.agentscope.core.event.ToolResultEndEvent; +import io.agentscope.core.event.AgentEventStreams; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.message.Msg; import io.agentscope.core.message.UserMessage; import io.agentscope.core.tool.Tool; @@ -32,13 +26,14 @@ import io.agentscope.core.tool.Toolkit; /** - * AgentEventStreamExample - Demonstrates {@link ReActAgent#streamEvents} and the - * {@link AgentEvent} hierarchy. + * AgentEventStreamExample - Demonstrates opt-in text output disposition events on top of {@link + * ReActAgent#streamEvents} and the {@link AgentEvent} hierarchy. * *

{@code streamEvents()} returns a {@link reactor.core.publisher.Flux}{@code } * that covers the full agent lifecycle: startup, each model call, every text token, tool - * invocations, tool results, and shutdown. This gives callers the granularity needed to build - * real-time UIs, audit logs, or cost trackers without custom middleware. + * invocations, tool results, and shutdown. {@link AgentEventStreams#withTextOutputDisposition} + * preserves those events and derives lifecycle signals that classify streamed text as intermediate + * or terminal. * *

Event sequence for a single-turn response (no tools): *

@@ -94,7 +89,7 @@ public static void main(String[] args) {
                         .toolkit(toolkit)
                         .build();
 
-        Msg userMsg = new UserMessage("user", "What is the weather like in Beijing and Shanghai?");
+        Msg input = new UserMessage("user", "What is the weather like in Beijing and Shanghai?");
 
         System.out.println("User: What is the weather like in Beijing and Shanghai?\n");
 
@@ -106,74 +101,18 @@ public static void main(String[] args) {
         //   event.getId()         — unique event ID
         //   event.getCreatedAt()  — ISO-8601 timestamp
         //
-        // Use instanceof to access type-specific fields.
-        agent.streamEvents(userMsg).doOnNext(AgentEventStreamExample::handleEvent).blockLast();
-    }
-
-    /**
-     * Dispatches an {@link AgentEvent} to a type-specific handler.
-     *
-     * 

The {@code instanceof} pattern is the recommended way to consume events because - * it gives compile-time access to typed fields without casting. An exhaustive - * {@code switch} on {@link io.agentscope.core.event.AgentEventType} is an alternative - * when only the type discriminator is needed. - * - * @param event the event to handle - */ - private static void handleEvent(AgentEvent event) { - if (event instanceof AgentStartEvent e) { - // Emitted once at the very beginning of an invocation. - // replyId correlates all subsequent events to this invocation. - System.out.printf( - "[AGENT_START] agent=%s replyId=%s%n", e.getName(), e.getReplyId()); - - } else if (event instanceof ModelCallStartEvent e) { - // Emitted before each model (LLM) API call. - // A multi-tool ReAct loop fires one MODEL_CALL_START per iteration. - System.out.printf("[MODEL_CALL_START] replyId=%s%n", e.getReplyId()); - - } else if (event instanceof TextBlockDeltaEvent e) { - // Emitted for every token chunk streamed from the model. - // Print without newline to render the response incrementally. - System.out.print(e.getDelta()); - - } else if (event instanceof ModelCallEndEvent e) { - // Emitted after the model call completes. - // ChatUsage carries input/output token counts when the model reports them. - System.out.println(); - if (e.getUsage() != null) { - System.out.printf( - "[MODEL_CALL_END] inputTokens=%d outputTokens=%d%n", - e.getUsage().getInputTokens(), e.getUsage().getOutputTokens()); - } else { - System.out.println("[MODEL_CALL_END]"); - } - - } else if (event instanceof ToolCallStartEvent e) { - // Emitted when the model requests a tool invocation. - // toolCallId correlates ToolCallStart/End and ToolResultStart/End pairs. - System.out.printf( - "[TOOL_CALL_START] tool=%s callId=%s%n", - e.getToolCallName(), e.getToolCallId()); - - } else if (event instanceof ToolCallEndEvent e) { - System.out.printf("[TOOL_CALL_END] callId=%s%n", e.getToolCallId()); - - } else if (event instanceof ToolResultEndEvent e) { - // Emitted after the tool result has been produced. - // ToolResultState is SUCCESS or ERROR. - System.out.printf( - "[TOOL_RESULT_END] callId=%s state=%s%n", e.getToolCallId(), e.getState()); - - } else if (event instanceof AgentEndEvent e) { - // Emitted once after the agent finishes all iterations. - System.out.printf("[AGENT_END] replyId=%s%n", e.getReplyId()); - - } else { - // All other events (TEXT_BLOCK_START/END, TOOL_RESULT_START, THINKING_BLOCK_*, etc.) - // are silently ignored in this example but are available for more advanced use cases. - System.out.printf("[%-24s] (skipped)%n", event.getType()); - } + // TERMINAL closes the streamed text lifecycle; AgentResultEvent remains the authoritative + // invocation result and may differ from the streamed preview. + AgentEventStreams.withTextOutputDisposition(agent.streamEvents(input)) + .doOnNext( + event -> { + if (event instanceof TextOutputDispositionEvent disposition) { + System.out.printf( + "%s -> %s%n", + disposition.getReplyId(), disposition.getDisposition()); + } + }) + .blockLast(); } /** Simulated weather tool used to trigger tool-call events. */ diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md new file mode 100644 index 0000000000..f6428efd36 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md @@ -0,0 +1,37 @@ +# AgentScope AG-UI extension + +This module converts AgentScope `AgentEvent` streams to AG-UI events. Text output disposition and +authoritative final-message calibration are opt-in so existing AG-UI event sequences and message +IDs remain unchanged by default. + +## Enable text output disposition + +```java +AguiAdapterConfig config = AguiAdapterConfig.builder() + .textOutputDispositionEnabled(true) + .build(); + +AguiAgentAdapter adapter = new AguiAgentAdapter(agent, config); +Flux events = adapter.run(input); +``` + +When enabled, the adapter derives `TextOutputDispositionEvent` values from the agent stream and +emits an AG-UI `CUSTOM` event named `agentscope.text_output.disposition`. Its value contains: + +- `replyId`: the AgentScope reply whose text lifecycle changed; +- `messageIds`: all AG-UI text segment IDs associated with that reply; +- `disposition`: `INTERMEDIATE` or `TERMINAL`; +- `generateReason`: the generation reason when one is available. + +`INTERMEDIATE` identifies text that should be presented as progress or commentary. `TERMINAL` +closes a streamed text lifecycle, but **does not mean that the text is the final answer**. The +authoritative invocation result remains `AgentResultEvent`. + +For completed top-level results, the opt-in adapter emits a standard AG-UI `MESSAGES_SNAPSHOT` +containing the authoritative result and available conversation state. Consumers should use that +snapshot to reconcile or replace streamed text. No snapshot is emitted for a pending interrupt or +for generation reasons that do not represent an ordinary completed answer. + +Leaving `textOutputDispositionEnabled` unset (or setting it to `false`) preserves the legacy +message ID (`replyId`) and event sequence and emits neither disposition `CUSTOM` events nor final +`MESSAGES_SNAPSHOT` calibration events. diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java index 8cbcd90968..0bffd8fdb1 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventCodec.java @@ -64,8 +64,10 @@ private RemoteEventCodec() {} * *

Every event also carries its full serialization in {@link RemoteAgentEvent#getPayload()}, * so a client can restore the original instance instead of the lossy flat fields. Event types - * without a dedicated wire type are forwarded as {@link RemoteEventType#AGENT_EVENT} and are - * only visible at {@code detail=verbose}. + * without a dedicated wire type are forwarded as {@link RemoteEventType#AGENT_EVENT}. At + * {@code detail=full}, text disposition and authoritative result subtypes are included; other + * passthrough subtypes remain visible only at {@code detail=verbose}. The payload stays a JSON + * string, and older clients may ignore the unknown passthrough subtype. */ public static Optional fromAgentEvent(AgentEvent event) { if (event == null) { diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventType.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventType.java index e7ed536074..89d3974141 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventType.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteEventType.java @@ -35,8 +35,9 @@ public enum RemoteEventType { /** * Carries an {@link io.agentscope.core.event.AgentEvent} that has no dedicated wire type, fully - * serialized in {@link RemoteAgentEvent#getPayload()}. Emitted only at {@code detail=verbose}; - * clients that predate this type skip it. + * serialized as a JSON string in {@link RemoteAgentEvent#getPayload()}. At {@code detail=full}, + * {@code TEXT_OUTPUT_DISPOSITION} and {@code AGENT_RESULT} are included; other subtypes require + * {@code detail=verbose}. Clients that predate this type may ignore it. */ AGENT_EVENT } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteStreamDetail.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteStreamDetail.java index fdee554a66..3e6c4bd036 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteStreamDetail.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/RemoteStreamDetail.java @@ -27,7 +27,12 @@ public enum RemoteStreamDetail { /** Lifecycle, tool call boundaries, tool results and confirmation requests. */ STATUS, - /** {@link #STATUS} plus text and thinking deltas. The default for a streaming parent. */ + /** + * {@link #STATUS} plus text and thinking deltas, text output disposition events, and + * authoritative agent result events. Disposition and result use {@link + * RemoteEventType#AGENT_EVENT} with a JSON string payload. This is the default for a streaming + * parent. + */ FULL, /** diff --git a/agentscope-service/docs/managed_agents/guide/07-events.md b/agentscope-service/docs/managed_agents/guide/07-events.md index 7def6b22c4..bbb3bdd5bf 100644 --- a/agentscope-service/docs/managed_agents/guide/07-events.md +++ b/agentscope-service/docs/managed_agents/guide/07-events.md @@ -26,10 +26,23 @@ curl -N "$BASE/api/sessions/$SESSION_ID/events/stream?event_deltas=agent.message |---|---| | `event_start` | 即将产生某持久化类型;payload 含 `event_id`、`type` | | `event_delta` | 增量文本;payload 含 `event_id`、`type`、`delta` | +| `event_update` | 更新同一预览的文本处置或权威结果状态;payload 含 `event_id`、`type` 及状态字段 | 完整 `agent.message` / `agent.thinking` 仍会在落库后推送。 `GET …/events` **永远看不到** delta。多副本下 deltas 仅 turn-owner best-effort。 +Managed Web 的 turn runner 已在服务端启用文本处置派生;客户端无需增加服务端配置,只需像上例一样订阅 +`event_deltas=agent.message`,即可收到同一 `event_id` 的 `event_update`: + +- `disposition=INTERMEDIATE`:当前预览只是过程文本,UI 可降级为 commentary。 +- `disposition=TERMINAL`:当前预览的文本生命周期结束;这**不等于最终答案**。 +- `authoritative=true`:权威 `AgentResultEvent` 已完成校准。`hasOutput=false` 表示应清除此前预览;有输出时, + 最终的持久化 `agent.message` 会复用该 `event_id` 并携带权威内容。 + +`event_update` 与 delta 一样只存在于 SSE 流中,不会落库。最终答案应以权威 +`AgentResultEvent` 映射出的 `agent.message`(或 `authoritative=true` 的空结果更新)为准,而不是仅凭 +`TERMINAL` 判定。 + ## 投递入站 ```bash From b8fd236f4d2ec4f924f16ba86b53a3d2a9af886a Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:45:34 +0800 Subject: [PATCH 12/22] =?UTF-8?q?docs(streaming):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=BA=8F=E5=88=97=E4=B8=8E=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=80=A7=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 22a30bfad040d90833641fd6fa1c2a95e2fd1426) --- .../task-9-report.md | 41 ++++++++- .../agent/ReActAgentNewLoopReplyTest.java | 23 +++-- .../streaming/AgentEventStreamExample.java | 13 +-- .../agent/subagent/SubagentDeclaration.java | 16 ++-- .../task/AgentProtocolTaskClientTest.java | 85 +++++++++++++++++++ .../docs/managed_agents/guide/07-events.md | 9 +- 6 files changed, 161 insertions(+), 26 deletions(-) create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/task/AgentProtocolTaskClientTest.java diff --git a/.superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md b/.superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md index 0034877771..01f9ad2676 100644 --- a/.superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md +++ b/.superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md @@ -43,7 +43,9 @@ Task 9 的文档与 Javadoc 已完成,并验证了 Core 显式启用方式、` - 修正 `RemoteEventCodec`、`RemoteEventType`、`RemoteStreamDetail` 的过时说明。 - 明确 `detail=full` 会包含 `TEXT_OUTPUT_DISPOSITION` 与 `AGENT_RESULT` 两种 `AGENT_EVENT` subtype;其余 passthrough subtype 仍仅在 `verbose` 下可见。 -- 明确 payload 仍是 JSON `String`,旧客户端可忽略未知 passthrough subtype。 +- 明确 payload 仍是 JSON `String`,`eventType` subtype 也仍是字符串;客户端可以忽略不理解的 subtype。 +- 区分两种 JSON 前向兼容机制:`READ_UNKNOWN_ENUM_VALUES_AS_NULL` 处理未知 wire enum,DTO 的 + `@JsonIgnoreProperties(ignoreUnknown = true)` 只处理未知字段。 ## 路径差异 @@ -103,9 +105,15 @@ Windows 符号链接权限: ## 默认兼容性复核 -- Core:`AgentStreamingTest.testStreamEventCount` 在 Core 回归中通过;示例通过 wrapper 显式 opt-in,未修改原始 `ReActAgent#streamEvents()`。 +- Core:`ReActAgentNewLoopReplyTest.unwrappedTextOnlyStreamPreservesLegacySequenceWithoutDisposition` + 直接调用未包装的 `ReActAgent#streamEvents()`,断言精确的 8 事件序列 + `AGENT_START → MODEL_CALL_START → TEXT_BLOCK_START → TEXT_BLOCK_DELTA → TEXT_BLOCK_END → MODEL_CALL_END → AGENT_RESULT → AGENT_END`, + 并断言不存在 `TextOutputDispositionEvent`。 - AG-UI:`AguiAdapterConfigTest.testDefaultConfig` 与 `testBuilderWithDefaults` 断言默认关闭;`AguiAgentAdapterV2Test.testTextOutputDispositionRemainsDisabledWithoutChangingLegacySequenceOrMessageId` 覆盖未启用时旧序列和 message ID,AG-UI 全模块 528 个测试通过。 -- Remote:`RemoteAgentEvent.payload` 类型仍为 `String`,DTO 保留 `@JsonIgnoreProperties(ignoreUnknown = true)`;`RemoteEventCodecTest.roundTripTextOutputDispositionAsAgentEventPayload` 与 `RemoteEventCodecPassthroughTest.payloadDecodesEvenWhenTheWireTypeIsUnknownToThisClient` 在 Harness 补充回归中通过。 +- Remote:`RemoteAgentEvent.payload` 类型仍为 `String`;`AgentProtocolTaskClient` 的 JSON mapper 通过 + `READ_UNKNOWN_ENUM_VALUES_AS_NULL` 将未知 `RemoteEventType` 读为 `null`,而 `RemoteAgentEvent` 的 + `@JsonIgnoreProperties(ignoreUnknown = true)` 独立忽略未知字段。新增 + `AgentProtocolTaskClientTest.unknownWireEnumAndFieldDoNotDropStringPayload` 通过真实 SSE JSON 路径同时验证这两点及字符串 payload 保留。 - Final answer filter:`FinalAnswerFilterMiddlewareTest` 的 `finalRoundEmitsBufferedTextBeforeModelCallEnd`、`intermediateRoundSuppressesTextWhenToolCallIsObserved`、`nonTextEventsAreForwarded`、`stateIsolatedAcrossSubscriptions` 均在 Core 回归中通过。 ## 自审 @@ -118,3 +126,30 @@ Windows 符号链接权限: - brief Step 6:将使用指定提交信息 `docs(streaming): 说明文本处置与结果校准用法` 提交,仅保留本任务文件与本报告。 未发现需要新增生产代码、修改默认行为或扩大文档范围的问题。 + +## 修复轮 1(2026-09-03) + +根据复审 findings 做了以下校正: + +- `AgentEventStreamExample` 不再声称当前只打印 disposition 的 callback 会显示所有生命周期/工具事件; + 无工具序列补入真实 `AGENT_RESULT`,并明确 opt-in wrapper 在 `AgentEndEvent` 前派生 + `TEXT_OUTPUT_DISPOSITION(TERMINAL)`,顺序为 `AGENT_RESULT → TEXT_OUTPUT_DISPOSITION → AGENT_END`。 +- `07-events.md` 明确 `authoritative=true, hasOutput=false` 的 `event_update` 只用于权威结果无输出时清空预览; + 普通非空结果不另发 authoritative update,而是由复用同一 ID 的持久化 `agent.message` 校准。 +- `SubagentDeclaration#getRemoteStreamDetail()` 及 builder 的公开 Javadoc 明确 FULL 还包括 + `TEXT_OUTPUT_DISPOSITION` 与 `AGENT_RESULT`。 +- 用真实 `ReActAgent#streamEvents()` characterization 测试替换不相关的旧数量证据;该测试在首次有效运行即通过, + 说明现有默认行为已经满足要求,因此没有伪造 RED 或修改生产逻辑。 +- 新增真实 SSE JSON characterization 测试,准确区分未知 enum 与未知字段的处理机制。首次命令因 PowerShell + 未给带点 Maven 属性加引号而未进入构建;第二次在测试前由 Spotless 报告两处格式差异;按建议修正后首次有效行为运行通过。 + +本轮新增验证: + +| 命令 | 退出码 | 结果 | +| --- | ---: | --- | +| `mvn -pl agentscope-core test -DskipITs "-Dtest=ReActAgentNewLoopReplyTest#unwrappedTextOnlyStreamPreservesLegacySequenceWithoutDisposition,AgentEventStreamsTest#emitsResultTerminalThenEndOnNormalCompletion"` | 0 | 2 tests,0 failures/errors;同时覆盖未包装默认序列和 wrapper 的 `AGENT_RESULT → TERMINAL → AGENT_END` 顺序 | +| `mvn -pl agentscope-harness -am test -DskipITs -Dtest=AgentProtocolTaskClientTest "-Dsurefire.failIfNoSpecifiedTests=false"` | 0 | Harness 目标测试 1/1 通过,3 模块 reactor SUCCESS | +| `mvn spotless:check -DskipTests` | 0 | 91 个 reactor 模块 SUCCESS | +| `mvn -pl agentscope-examples/documentation -am -DskipTests compile` | 0 | 27 个 reactor 模块 SUCCESS,Documentation 50 个源文件编译成功 | + +本轮没有修改生产行为;只收紧兼容性测试、修正文档/Javadoc,并新增 Remote JSON 边界测试。 diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java index 2aa91f88f8..d7a6321ea5 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java @@ -16,12 +16,14 @@ package io.agentscope.core.agent; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import io.agentscope.core.ReActAgent; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentEventType; import io.agentscope.core.event.AgentResultEvent; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.ExceedMaxItersEvent; @@ -32,6 +34,7 @@ import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; +import io.agentscope.core.event.TextOutputDispositionEvent; import io.agentscope.core.event.ThinkingBlockDeltaEvent; import io.agentscope.core.event.ThinkingBlockEndEvent; import io.agentscope.core.event.ThinkingBlockStartEvent; @@ -174,7 +177,7 @@ public Mono callAsync(ToolCallParam param) { } @Test - void textOnlyReplyEmitsExpectedEventOrder() { + void unwrappedTextOnlyStreamPreservesLegacySequenceWithoutDisposition() { ChatModelBase model = new ScriptedModel(List.of(() -> Flux.just(textResponse("hello world")))); ReActAgent agent = @@ -188,12 +191,18 @@ void textOnlyReplyEmitsExpectedEventOrder() { List events = agent.streamEvents(List.of()).collectList().block(); assertNotNull(events); - assertTrue(events.get(0) instanceof AgentStartEvent); - assertTrue(events.get(events.size() - 1) instanceof AgentEndEvent); - long modelStarts = events.stream().filter(e -> e instanceof ModelCallStartEvent).count(); - long modelEnds = events.stream().filter(e -> e instanceof ModelCallEndEvent).count(); - assertEquals(1L, modelStarts); - assertEquals(1L, modelEnds); + assertEquals( + List.of( + AgentEventType.AGENT_START, + AgentEventType.MODEL_CALL_START, + AgentEventType.TEXT_BLOCK_START, + AgentEventType.TEXT_BLOCK_DELTA, + AgentEventType.TEXT_BLOCK_END, + AgentEventType.MODEL_CALL_END, + AgentEventType.AGENT_RESULT, + AgentEventType.AGENT_END), + events.stream().map(AgentEvent::getType).toList()); + assertFalse(events.stream().anyMatch(TextOutputDispositionEvent.class::isInstance)); } @Test diff --git a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java index f8b8db8fa8..60acb5ebae 100644 --- a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java +++ b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/streaming/AgentEventStreamExample.java @@ -26,14 +26,15 @@ import io.agentscope.core.tool.Toolkit; /** - * AgentEventStreamExample - Demonstrates opt-in text output disposition events on top of {@link - * ReActAgent#streamEvents} and the {@link AgentEvent} hierarchy. + * AgentEventStreamExample - Demonstrates the opt-in text output disposition wrapper on top of + * {@link ReActAgent#streamEvents} and the {@link AgentEvent} hierarchy. * *

{@code streamEvents()} returns a {@link reactor.core.publisher.Flux}{@code } * that covers the full agent lifecycle: startup, each model call, every text token, tool * invocations, tool results, and shutdown. {@link AgentEventStreams#withTextOutputDisposition} * preserves those events and derives lifecycle signals that classify streamed text as intermediate - * or terminal. + * or terminal. This example prints only those derived disposition events; callers can inspect the + * unchanged underlying events in the same callback when needed. * *

Event sequence for a single-turn response (no tools): *

@@ -43,10 +44,12 @@
  *         TEXT_BLOCK_DELTA  (repeated — one per streamed token chunk)
  *       TEXT_BLOCK_END
  *     MODEL_CALL_END        (carries token usage)
+ *   AGENT_RESULT            (authoritative invocation result)
+ *   TEXT_OUTPUT_DISPOSITION (TERMINAL, derived by the opt-in wrapper)
  *   AGENT_END
  * 
* - *

Additional events when a tool is called: + *

Additional underlying events when a tool is called: *

  *     TOOL_CALL_START       (tool name + call ID)
  *       TOOL_CALL_DELTA     (optional — streamed tool input)
@@ -75,7 +78,7 @@ public static void main(String[] args) {
         System.out.println("AgentEvent Stream Example");
         System.out.println("=".repeat(60));
         System.out.println(
-                "Shows every lifecycle event emitted by streamEvents(), including tool calls.");
+                "Prints derived text dispositions while preserving the underlying event stream.");
         System.out.println("=".repeat(60) + "\n");
 
         Toolkit toolkit = new Toolkit();
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java
index 10264f86c3..da0f05566f 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java
@@ -354,10 +354,11 @@ public boolean isRemoteStreaming() {
 
     /**
      * How much of the remote event stream to forward. Defaults to {@link RemoteStreamDetail#FULL}
-     * (lifecycle, tool calls, text and thinking deltas). Use {@link RemoteStreamDetail#VERBOSE} to
-     * mirror a local subagent's stream in full — block boundaries, tool output deltas, model calls
-     * with token usage and every other event — at the cost of more traffic. Only relevant when
-     * {@link #isRemoteStreaming()} is true.
+     * (lifecycle, tool calls, text and thinking deltas, text output disposition, and authoritative
+     * agent result events). Use {@link RemoteStreamDetail#VERBOSE} to mirror a local subagent's
+     * stream in full — block boundaries, tool output deltas, model calls with token usage and every
+     * other event — at the cost of more traffic. Only relevant when {@link #isRemoteStreaming()} is
+     * true.
      */
     public RemoteStreamDetail getRemoteStreamDetail() {
         return remoteStreamDetail != null ? remoteStreamDetail : RemoteStreamDetail.FULL;
@@ -629,9 +630,10 @@ public Builder remoteStreaming(Boolean remoteStreaming) {
 
         /**
          * How much of the remote event stream to forward. {@code null} (default) is treated as
-         * {@link RemoteStreamDetail#FULL}; {@link RemoteStreamDetail#VERBOSE} forwards every event
-         * the remote agent emits, matching a local subagent. Only relevant when
-         * {@link #remoteStreaming(Boolean)} is enabled.
+         * {@link RemoteStreamDetail#FULL}, including text output disposition and authoritative
+         * agent result events; {@link RemoteStreamDetail#VERBOSE} forwards every event the remote
+         * agent emits, matching a local subagent. Only relevant when {@link
+         * #remoteStreaming(Boolean)} is enabled.
          */
         public Builder remoteStreamDetail(RemoteStreamDetail remoteStreamDetail) {
             this.remoteStreamDetail = remoteStreamDetail;
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/task/AgentProtocolTaskClientTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/task/AgentProtocolTaskClientTest.java
new file mode 100644
index 0000000000..b2b86dc21f
--- /dev/null
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/task/AgentProtocolTaskClientTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.harness.agent.subagent.task;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.net.httpserver.HttpServer;
+import io.agentscope.harness.agent.subagent.protocol.RemoteAgentEvent;
+import java.io.Closeable;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+
+class AgentProtocolTaskClientTest {
+
+    @Test
+    void unknownWireEnumAndFieldDoNotDropStringPayload() throws Exception {
+        byte[] body =
+                ("data: {\"seq\":7,\"type\":\"FUTURE_EVENT\",\"taskId\":\"task-1\","
+                                + "\"payload\":\"{\\\"event\\\":\\\"kept\\\"}\","
+                                + "\"futureField\":\"ignored\"}\n\n")
+                        .getBytes(UTF_8);
+        HttpServer server =
+                HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+        server.createContext(
+                "/tasks/task-1/events",
+                exchange -> {
+                    exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
+                    exchange.sendResponseHeaders(200, body.length);
+                    try (var response = exchange.getResponseBody()) {
+                        response.write(body);
+                    }
+                });
+        server.start();
+
+        CountDownLatch received = new CountDownLatch(1);
+        AtomicReference captured = new AtomicReference<>();
+        try {
+            AgentProtocolTaskClient client = new AgentProtocolTaskClient();
+            String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
+            try (Closeable stream =
+                    client.openEventStream(
+                            baseUrl,
+                            Map.of(),
+                            "task-1",
+                            0,
+                            event -> {
+                                captured.set(event);
+                                received.countDown();
+                            })) {
+                assertTrue(
+                        received.await(5, TimeUnit.SECONDS),
+                        "future wire enum event should remain parseable");
+            }
+        } finally {
+            server.stop(0);
+        }
+
+        RemoteAgentEvent event = captured.get();
+        assertNull(event.getType());
+        assertEquals(7, event.getSeq());
+        assertEquals("task-1", event.getTaskId());
+        assertEquals("{\"event\":\"kept\"}", event.getPayload());
+    }
+}
diff --git a/agentscope-service/docs/managed_agents/guide/07-events.md b/agentscope-service/docs/managed_agents/guide/07-events.md
index bbb3bdd5bf..8b3173b36b 100644
--- a/agentscope-service/docs/managed_agents/guide/07-events.md
+++ b/agentscope-service/docs/managed_agents/guide/07-events.md
@@ -26,7 +26,7 @@ curl -N "$BASE/api/sessions/$SESSION_ID/events/stream?event_deltas=agent.message
 |---|---|
 | `event_start` | 即将产生某持久化类型;payload 含 `event_id`、`type` |
 | `event_delta` | 增量文本;payload 含 `event_id`、`type`、`delta` |
-| `event_update` | 更新同一预览的文本处置或权威结果状态;payload 含 `event_id`、`type` 及状态字段 |
+| `event_update` | 更新同一预览的文本处置;权威结果无输出时也用于清空预览;payload 含 `event_id`、`type` 及状态字段 |
 
 完整 `agent.message` / `agent.thinking` 仍会在落库后推送。  
 `GET …/events` **永远看不到** delta。多副本下 deltas 仅 turn-owner best-effort。
@@ -36,11 +36,12 @@ Managed Web 的 turn runner 已在服务端启用文本处置派生;客户端
 
 - `disposition=INTERMEDIATE`:当前预览只是过程文本,UI 可降级为 commentary。
 - `disposition=TERMINAL`:当前预览的文本生命周期结束;这**不等于最终答案**。
-- `authoritative=true`:权威 `AgentResultEvent` 已完成校准。`hasOutput=false` 表示应清除此前预览;有输出时,
-  最终的持久化 `agent.message` 会复用该 `event_id` 并携带权威内容。
+- `authoritative=true, hasOutput=false`:仅在权威 `AgentResultEvent` 没有输出时发送,用于清除此前预览。
+- 普通非空权威结果**不会**另发 `authoritative=true` 的 `event_update`;最终持久化的 `agent.message` 会复用
+  该 `event_id` 并携带权威内容,以此校准或替换预览。
 
 `event_update` 与 delta 一样只存在于 SSE 流中,不会落库。最终答案应以权威
-`AgentResultEvent` 映射出的 `agent.message`(或 `authoritative=true` 的空结果更新)为准,而不是仅凭
+`AgentResultEvent` 映射出的 `agent.message`(或 `authoritative=true, hasOutput=false` 的空结果更新)为准,而不是仅凭
 `TERMINAL` 判定。
 
 ## 投递入站

From 230cd2ae2a996e3b943ce6001620494f9df1a734 Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Fri, 4 Sep 2026 10:15:38 +0800
Subject: [PATCH 13/22] =?UTF-8?q?fix(streaming):=20=E4=BF=AE=E6=AD=A3?=
 =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=A4=84=E7=BD=AE=E7=9A=84=E8=B0=83=E7=94=A8?=
 =?UTF-8?q?=E7=BB=93=E6=9D=9F=E5=85=B3=E8=81=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

按来源和任务关闭最后模型回复,并区分子代理成功、失败与取消结束。

补齐协议、AG-UI、前端和数据面的回归验证与最终修复报告。

(cherry picked from commit 80c3595e66bcfc034fa64dc76586d37cda2031c4)
---
 .../agentscope/core/event/AgentEndEvent.java  |  7 ++
 .../core/event/AgentEventStreams.java         | 12 ++-
 .../core/event/AgentEventStreamsTest.java     | 44 +++++++++
 .../agentprotocol/AgentProtocolTaskStore.java |  4 +-
 .../AgentProtocolStreamDetailTest.java        | 49 +++++++---
 .../adapter/strategy/AguiStreamContext.java   | 40 ++++++++
 .../TextOutputDispositionConverter.java       | 21 +---
 .../agui/adapter/AguiAgentAdapterV2Test.java  | 61 ++++++++++++
 .../harness/agent/tool/AgentSpawnTool.java    | 18 ++--
 .../AgentSpawnToolCancelEndEventTest.java     | 97 ++++++++++++++++++-
 10 files changed, 310 insertions(+), 43 deletions(-)

diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java
index 98a594ae8b..9ae68da3ba 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java
@@ -23,6 +23,13 @@
  */
 public class AgentEndEvent extends AgentEvent {
 
+    /** Metadata key describing whether a synthesized invocation end succeeded, failed, or cancelled. */
+    public static final String METADATA_INVOCATION_OUTCOME = "invocationOutcome";
+
+    public static final String OUTCOME_SUCCESS = "success";
+    public static final String OUTCOME_ERROR = "error";
+    public static final String OUTCOME_CANCELLED = "cancelled";
+
     private final String replyId;
 
     @JsonCreator
diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java
index 0bcc38dd31..f148b684e3 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java
@@ -131,7 +131,7 @@ private List onAgentEnd(AgentEndEvent event, Observation observation
 
             ReplySnapshot current = observation.after();
             List output = new ArrayList<>(2);
-            if (observation.currentReplyEvent() && hasUnclassifiedText(current)) {
+            if (isNormallyCompleted(event) && hasUnclassifiedText(current)) {
                 output.add(
                         disposition(
                                 current.replyId(), TextOutputDisposition.TERMINAL, null, event));
@@ -148,7 +148,7 @@ private List complete() {
                 AgentEndEvent end = entry.getValue();
                 ReplySnapshot current = tracker.snapshot(sourceKey);
                 AgentResultEvent result = current.lastResult();
-                if (Objects.equals(end.getReplyId(), current.replyId())
+                if (isNormallyCompleted(end)
                         && hasUnclassifiedText(current)
                         && result != null
                         && result.getResult() != null) {
@@ -168,6 +168,14 @@ && hasUnclassifiedText(current)
             return output;
         }
 
+        private static boolean isNormallyCompleted(AgentEndEvent end) {
+            Object outcome =
+                    end.getMetadata() == null
+                            ? null
+                            : end.getMetadata().get(AgentEndEvent.METADATA_INVOCATION_OUTCOME);
+            return outcome == null || AgentEndEvent.OUTCOME_SUCCESS.equals(outcome.toString());
+        }
+
         private static boolean hasUnclassifiedText(ReplySnapshot snapshot) {
             return snapshot.replyId() != null
                     && snapshot.textSeen()
diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java
index 776856388c..47927ac134 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java
@@ -17,11 +17,16 @@
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 
+import io.agentscope.core.ReActAgent;
+import io.agentscope.core.agent.test.MockModel;
 import io.agentscope.core.message.AssistantMessage;
 import io.agentscope.core.message.GenerateReason;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.junit.jupiter.api.Test;
@@ -31,6 +36,45 @@
 
 class AgentEventStreamsTest {
 
+    @Test
+    void realReActAgentClosesLastModelReplyAtInvocationEnd() {
+        ReActAgent agent =
+                ReActAgent.builder().name("test-agent").model(new MockModel("answer")).build();
+        Msg input = Msg.builder().role(MsgRole.USER).textContent("hello").build();
+
+        List events =
+                AgentEventStreams.withTextOutputDisposition(agent.streamEvents(List.of(input)))
+                        .collectList()
+                        .block();
+
+        ModelCallStartEvent modelStart =
+                events.stream()
+                        .filter(ModelCallStartEvent.class::isInstance)
+                        .map(ModelCallStartEvent.class::cast)
+                        .findFirst()
+                        .orElseThrow();
+        AgentEndEvent agentEnd =
+                events.stream()
+                        .filter(AgentEndEvent.class::isInstance)
+                        .map(AgentEndEvent.class::cast)
+                        .findFirst()
+                        .orElseThrow();
+        TextOutputDispositionEvent terminal =
+                events.stream()
+                        .filter(TextOutputDispositionEvent.class::isInstance)
+                        .map(TextOutputDispositionEvent.class::cast)
+                        .filter(
+                                disposition ->
+                                        disposition.getDisposition()
+                                                == TextOutputDisposition.TERMINAL)
+                        .findFirst()
+                        .orElseThrow();
+
+        assertNotEquals(modelStart.getReplyId(), agentEnd.getReplyId());
+        assertEquals(modelStart.getReplyId(), terminal.getReplyId());
+        assertEquals(GenerateReason.MODEL_STOP, terminal.getGenerateReason());
+    }
+
     @Test
     void emitsResultTerminalThenEndOnNormalCompletion() {
         AgentResultEvent result = result(GenerateReason.MODEL_STOP);
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/main/java/io/agentscope/extensions/agentprotocol/AgentProtocolTaskStore.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/main/java/io/agentscope/extensions/agentprotocol/AgentProtocolTaskStore.java
index 7448db21a1..fe2e8d5801 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/main/java/io/agentscope/extensions/agentprotocol/AgentProtocolTaskStore.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/main/java/io/agentscope/extensions/agentprotocol/AgentProtocolTaskStore.java
@@ -19,6 +19,7 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 import io.agentscope.core.agent.RuntimeContext;
 import io.agentscope.core.event.AgentEvent;
+import io.agentscope.core.event.AgentEventStreams;
 import io.agentscope.core.event.AgentResultEvent;
 import io.agentscope.core.event.ConfirmResult;
 import io.agentscope.core.message.GenerateReason;
@@ -244,7 +245,8 @@ private String runAgent(
             AtomicReference resultRef = new AtomicReference<>();
             String detail = submitCtx.detail();
 
-            Flux events = agent.streamEvents(msg, ctx);
+            Flux events =
+                    AgentEventStreams.withTextOutputDisposition(agent.streamEvents(msg, ctx));
             events.doOnNext(
                             event -> {
                                 if (event instanceof AgentResultEvent resultEvent) {
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java
index c3bf10f58c..fdeb3ab7fc 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/src/test/java/io/agentscope/extensions/agentprotocol/AgentProtocolStreamDetailTest.java
@@ -28,12 +28,13 @@
 import io.agentscope.core.event.AgentResultEvent;
 import io.agentscope.core.event.AgentStartEvent;
 import io.agentscope.core.event.ModelCallEndEvent;
+import io.agentscope.core.event.ModelCallStartEvent;
 import io.agentscope.core.event.TextBlockDeltaEvent;
 import io.agentscope.core.event.TextBlockEndEvent;
 import io.agentscope.core.event.TextBlockStartEvent;
-import io.agentscope.core.event.TextOutputDisposition;
 import io.agentscope.core.event.TextOutputDispositionEvent;
 import io.agentscope.core.event.ToolResultTextDeltaEvent;
+import io.agentscope.core.message.GenerateReason;
 import io.agentscope.core.message.Msg;
 import io.agentscope.core.message.MsgRole;
 import io.agentscope.core.model.ChatUsage;
@@ -47,6 +48,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -63,28 +65,39 @@ class AgentProtocolStreamDetailTest {
 
     private ProtocolTaskRepository taskRepository;
     private HarnessAgent agent;
+    private AtomicInteger agentStreamSubscriptions;
 
     @BeforeEach
     void setUp() {
         taskRepository = new WorkspaceProtocolTaskRepository(tempDir);
         agent = mock(HarnessAgent.class);
+        agentStreamSubscriptions = new AtomicInteger();
         when(agent.streamEvents(any(Msg.class), any(RuntimeContext.class)))
-                .thenReturn(Flux.fromIterable(agentRun()));
+                .thenReturn(
+                        Flux.defer(
+                                () -> {
+                                    agentStreamSubscriptions.incrementAndGet();
+                                    return Flux.fromIterable(agentRun());
+                                }));
     }
 
     /** A run touching a wire-typed event, a delta, and several passthrough-only events. */
     private static List agentRun() {
         return List.of(
-                new AgentStartEvent("sess", "reply", "worker"),
-                new TextBlockStartEvent("reply", "b1"),
-                new TextBlockDeltaEvent("reply", "b1", "hello"),
-                new TextBlockEndEvent("reply", "b1"),
-                new TextOutputDispositionEvent("reply", TextOutputDisposition.TERMINAL, null),
-                new ToolResultTextDeltaEvent("reply", "call-1", "read_file", "file contents"),
-                new ModelCallEndEvent("reply", new ChatUsage(10, 20, 0, 0.5)),
+                new AgentStartEvent("sess", "invocation-reply", "worker"),
+                new ModelCallStartEvent("model-reply"),
+                new TextBlockStartEvent("model-reply", "b1"),
+                new TextBlockDeltaEvent("model-reply", "b1", "hello"),
+                new TextBlockEndEvent("model-reply", "b1"),
+                new ToolResultTextDeltaEvent("model-reply", "call-1", "read_file", "file contents"),
+                new ModelCallEndEvent("model-reply", new ChatUsage(10, 20, 0, 0.5)),
                 new AgentResultEvent(
-                        Msg.builder().role(MsgRole.ASSISTANT).textContent("done").build()),
-                new AgentEndEvent("reply"));
+                        Msg.builder()
+                                .role(MsgRole.ASSISTANT)
+                                .textContent("done")
+                                .generateReason(GenerateReason.MODEL_STOP)
+                                .build()),
+                new AgentEndEvent("invocation-reply"));
     }
 
     @Test
@@ -116,6 +129,15 @@ void fullLevelAddsDeltasAndAuthoritativePassthroughEvents() {
         assertTrue(eventTypes.contains("AGENT_RESULT"), eventTypes.toString());
         assertFalse(eventTypes.contains("TEXT_BLOCK_START"), eventTypes.toString());
         assertFalse(eventTypes.contains("TOOL_RESULT_TEXT_DELTA"), eventTypes.toString());
+
+        TextOutputDispositionEvent disposition =
+                events.stream()
+                        .filter(event -> "TEXT_OUTPUT_DISPOSITION".equals(event.getEventType()))
+                        .findFirst()
+                        .flatMap(RemoteEventCodec::toAgentEvent)
+                        .map(TextOutputDispositionEvent.class::cast)
+                        .orElseThrow();
+        assertEquals("model-reply", disposition.getReplyId());
     }
 
     @Test
@@ -160,6 +182,9 @@ private List collect(String detail, String taskId) {
 
         Flux subscription = bus.subscribe(taskId, 0L);
         store.submit(taskId, "worker", "go", Map.of("detail", detail));
-        return subscription.take(Duration.ofSeconds(5)).collectList().block();
+        List events =
+                subscription.take(Duration.ofSeconds(5)).collectList().block();
+        assertEquals(1, agentStreamSubscriptions.get(), "the agent stream must execute once");
+        return events;
     }
 }
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
index e55e7b7391..a6e7923481 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
@@ -24,6 +24,8 @@
 import io.agentscope.core.event.AgentEndEvent;
 import io.agentscope.core.event.AgentEvent;
 import io.agentscope.core.event.AgentResultEvent;
+import io.agentscope.core.event.TextOutputDisposition;
+import io.agentscope.core.event.TextOutputDispositionEvent;
 import io.agentscope.core.message.ContentBlock;
 import io.agentscope.core.message.GenerateReason;
 import io.agentscope.core.message.Msg;
@@ -32,6 +34,7 @@
 import io.agentscope.core.util.JsonException;
 import io.agentscope.core.util.JsonUtils;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.EnumSet;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
@@ -75,6 +78,8 @@ public class AguiStreamContext {
     private String currentReasoningMessageId;
     private final Map> textMessageIdsByReply = new LinkedHashMap<>();
     private final Map activeTextMessageIdsByReply = new LinkedHashMap<>();
+    private final Map textOutputDispositionsByReply =
+            new LinkedHashMap<>();
     private final Map toolResultContent = new LinkedHashMap<>();
     private final Map pendingInterrupts = new LinkedHashMap<>();
     private final Set warnedMissingToolCallIdOperations = new LinkedHashSet<>();
@@ -177,6 +182,7 @@ public void startTextMessage(String replyId) {
         String messageId = resolveTextMessageId(replyId);
         if (startedTextMessages.add(messageId)) {
             emit(new AguiEvent.TextMessageStart(threadId, runId, messageId, "assistant"));
+            emitRememberedTextOutputDisposition(replyId);
         }
         currentTextReplyId = replyId;
         currentTextMessageId = messageId;
@@ -228,6 +234,14 @@ public List getTextMessageIds(String replyId) {
         return List.copyOf(textMessageIdsByReply.getOrDefault(replyId, List.of()));
     }
 
+    public void emitTextOutputDisposition(TextOutputDispositionEvent dispositionEvent) {
+        TextOutputDispositionState disposition =
+                new TextOutputDispositionState(
+                        dispositionEvent.getDisposition(), dispositionEvent.getGenerateReason());
+        textOutputDispositionsByReply.put(dispositionEvent.getReplyId(), disposition);
+        emitTextOutputDisposition(dispositionEvent.getReplyId(), disposition);
+    }
+
     public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) {
         if (!config.isTextOutputDispositionEnabled()
                 || !isBlank(endEvent.getSource())
@@ -457,6 +471,29 @@ private String resolveTextMessageId(String replyId) {
                 });
     }
 
+    private void emitRememberedTextOutputDisposition(String replyId) {
+        TextOutputDispositionState disposition = textOutputDispositionsByReply.get(replyId);
+        if (disposition != null) {
+            emitTextOutputDisposition(replyId, disposition);
+        }
+    }
+
+    private void emitTextOutputDisposition(String replyId, TextOutputDispositionState disposition) {
+        Map value = new LinkedHashMap<>();
+        value.put("replyId", replyId);
+        value.put("messageIds", getTextMessageIds(replyId));
+        value.put("disposition", disposition.disposition().name());
+        value.put(
+                "generateReason",
+                disposition.generateReason() != null ? disposition.generateReason().name() : null);
+        emit(
+                new AguiEvent.Custom(
+                        threadId,
+                        runId,
+                        TextOutputDispositionConverter.EVENT_NAME,
+                        Collections.unmodifiableMap(value)));
+    }
+
     private static boolean isTextSegmentId(String messageId) {
         return messageId != null && TEXT_SEGMENT_ID.matcher(messageId).matches();
     }
@@ -476,6 +513,9 @@ private static String serialize(ContentBlock data) {
         }
     }
 
+    private record TextOutputDispositionState(
+            TextOutputDisposition disposition, GenerateReason generateReason) {}
+
     private static boolean isBlank(String value) {
         return value == null || value.isBlank();
     }
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java
index b2e2e0bcf4..b38c674f2e 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextOutputDispositionConverter.java
@@ -15,12 +15,8 @@
  */
 package io.agentscope.core.agui.adapter.strategy;
 
-import io.agentscope.core.agui.event.AguiEvent;
 import io.agentscope.core.event.AgentEvent;
 import io.agentscope.core.event.TextOutputDispositionEvent;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.Map;
 import java.util.Set;
 
 /** Converts opt-in text output lifecycle signals to AG-UI custom events and final snapshots. */
@@ -35,21 +31,6 @@ public Set> eventTypes() {
 
     @Override
     public void convert(AgentEvent event, AguiStreamContext context) {
-        TextOutputDispositionEvent dispositionEvent = (TextOutputDispositionEvent) event;
-        Map value = new LinkedHashMap<>();
-        value.put("replyId", dispositionEvent.getReplyId());
-        value.put("messageIds", context.getTextMessageIds(dispositionEvent.getReplyId()));
-        value.put("disposition", dispositionEvent.getDisposition().name());
-        value.put(
-                "generateReason",
-                dispositionEvent.getGenerateReason() != null
-                        ? dispositionEvent.getGenerateReason().name()
-                        : null);
-        context.emit(
-                new AguiEvent.Custom(
-                        context.getThreadId(),
-                        context.getRunId(),
-                        EVENT_NAME,
-                        Collections.unmodifiableMap(value)));
+        context.emitTextOutputDisposition((TextOutputDispositionEvent) event);
     }
 }
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
index 15cb555b73..ba5d5ee0d0 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
@@ -439,6 +439,67 @@ void testEnabledDispositionUsesSegmentIdsAndEmitsOneCustomEventWithoutReasoning(
                             .anyMatch(event -> event.getType().name().startsWith("REASONING")));
         }
 
+        @Test
+        void testToolFollowupSegmentInheritsSingleIntermediateDisposition() {
+            AguiAdapterConfig config =
+                    AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build();
+            List events =
+                    runReActEvents(
+                            config,
+                            new ModelCallStartEvent("reply-1"),
+                            new TextBlockDeltaEvent("reply-1", "text-1", "first"),
+                            new TextBlockEndEvent("reply-1", "text-1"),
+                            new ToolCallStartEvent("reply-1", "tool-1", "lookup"),
+                            new ToolCallEndEvent("reply-1", "tool-1", "lookup"),
+                            new TextBlockDeltaEvent("reply-1", "text-2", "second"),
+                            new TextBlockEndEvent("reply-1", "text-2"));
+
+            assertEquals(
+                    List.of(
+                            "reply-1:text:0",
+                            "reply-1:text:0",
+                            "reply-1:text:0",
+                            "reply-1:text:1",
+                            "reply-1:text:1",
+                            "reply-1:text:1"),
+                    events.stream()
+                            .filter(
+                                    event ->
+                                            event instanceof AguiEvent.TextMessageStart
+                                                    || event instanceof AguiEvent.TextMessageContent
+                                                    || event instanceof AguiEvent.TextMessageEnd)
+                            .map(
+                                    event -> {
+                                        if (event instanceof AguiEvent.TextMessageStart start) {
+                                            return start.messageId();
+                                        }
+                                        if (event instanceof AguiEvent.TextMessageContent content) {
+                                            return content.messageId();
+                                        }
+                                        return ((AguiEvent.TextMessageEnd) event).messageId();
+                                    })
+                            .toList());
+            List dispositions =
+                    events.stream()
+                            .filter(AguiEvent.Custom.class::isInstance)
+                            .map(AguiEvent.Custom.class::cast)
+                            .filter(
+                                    event ->
+                                            "agentscope.text_output.disposition"
+                                                    .equals(event.name()))
+                            .toList();
+            assertEquals(2, dispositions.size());
+            assertEquals(
+                    List.of("reply-1:text:0"), customValue(dispositions.get(0)).get("messageIds"));
+            assertEquals(
+                    List.of("reply-1:text:0", "reply-1:text:1"),
+                    customValue(dispositions.get(1)).get("messageIds"));
+            assertEquals("INTERMEDIATE", customValue(dispositions.get(1)).get("disposition"));
+            assertFalse(
+                    events.stream()
+                            .anyMatch(event -> event.getType().name().startsWith("REASONING")));
+        }
+
         @ParameterizedTest
         @EnumSource(
                 value = GenerateReason.class,
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 45922f4437..e23e8fa1bc 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
@@ -69,6 +69,7 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import reactor.core.Disposable;
@@ -795,12 +796,16 @@ private Mono execLocalSync(
                                         taskId));
 
                         AtomicBoolean endEmitted = new AtomicBoolean();
-                        Runnable emitEnd =
-                                () -> {
+                        Consumer emitEnd =
+                                outcome -> {
                                     if (endEmitted.compareAndSet(false, true)) {
                                         parentEmitter.emit(
                                                 tagForwardedEvent(
-                                                        new AgentEndEvent(replyId),
+                                                        new AgentEndEvent(replyId)
+                                                                .withMetadataEntry(
+                                                                        AgentEndEvent
+                                                                                .METADATA_INVOCATION_OUTCOME,
+                                                                        outcome),
                                                         sourcePath,
                                                         taskId));
                                     }
@@ -814,14 +819,15 @@ private Mono execLocalSync(
                                                         taggedEmitter))
                                 // Emit before success or error reaches the parent, which may
                                 // otherwise complete its event sink before doFinally runs.
-                                .doOnSuccess(ignored -> emitEnd.run())
-                                .doOnError(ignored -> emitEnd.run())
+                                .doOnSuccess(
+                                        ignored -> emitEnd.accept(AgentEndEvent.OUTCOME_SUCCESS))
+                                .doOnError(ignored -> emitEnd.accept(AgentEndEvent.OUTCOME_ERROR))
                                 // Preserve best-effort cancellation signaling without emitting a
                                 // duplicate if cancellation races with normal termination.
                                 .doFinally(
                                         signal -> {
                                             if (signal == SignalType.CANCEL) {
-                                                emitEnd.run();
+                                                emitEnd.accept(AgentEndEvent.OUTCOME_CANCELLED);
                                             }
                                         });
                     }
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolCancelEndEventTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolCancelEndEventTest.java
index e4a35e7a07..08b417211f 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolCancelEndEventTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/tool/AgentSpawnToolCancelEndEventTest.java
@@ -16,6 +16,7 @@
 package io.agentscope.harness.agent.tool;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
@@ -25,7 +26,12 @@
 import io.agentscope.core.event.AgentEndEvent;
 import io.agentscope.core.event.AgentEvent;
 import io.agentscope.core.event.AgentEventEmitter;
+import io.agentscope.core.event.AgentEventStreams;
 import io.agentscope.core.event.AgentStartEvent;
+import io.agentscope.core.event.ModelCallStartEvent;
+import io.agentscope.core.event.TextBlockDeltaEvent;
+import io.agentscope.core.event.TextOutputDisposition;
+import io.agentscope.core.event.TextOutputDispositionEvent;
 import io.agentscope.core.message.Msg;
 import io.agentscope.harness.agent.HarnessAgent;
 import io.agentscope.harness.agent.middleware.SubagentEntry;
@@ -44,6 +50,7 @@
 import org.junit.jupiter.api.Timeout;
 import org.mockito.Mockito;
 import reactor.core.Disposable;
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
 /**
@@ -83,6 +90,10 @@  T first(Class type) {
                     .findFirst()
                     .orElseThrow();
         }
+
+        List snapshot() {
+            return List.copyOf(events);
+        }
     }
 
     @Test
@@ -96,7 +107,10 @@ void parentCancel_emitsAgentEndEvent() throws Exception {
         Mockito.when(harness.getDelegate()).thenReturn(delegate);
         // Child never finishes on its own, so the only way this Mono terminates is cancel.
         Mockito.when(harness.call(any(Msg.class), any(RuntimeContext.class)))
-                .thenReturn(Mono.never().doOnSubscribe(ignored -> childStarted.countDown()));
+                .thenReturn(
+                        emitVisibleTextThen(
+                                Mono.never()
+                                        .doOnSubscribe(ignored -> childStarted.countDown())));
 
         DefaultAgentManager manager =
                 new DefaultAgentManager(
@@ -135,6 +149,15 @@ void parentCancel_emitsAgentEndEvent() throws Exception {
                         + " without a matching AgentEndEvent leaves consumers rendering the"
                         + " subagent as running forever (doOnTerminate does not fire on cancel)");
         assertReplyIdPair(emitter);
+        assertEquals(
+                AgentEndEvent.OUTCOME_CANCELLED,
+                emitter.first(AgentEndEvent.class)
+                        .getMetadata()
+                        .get(AgentEndEvent.METADATA_INVOCATION_OUTCOME));
+        assertFalse(
+                annotatedEvents(emitter).stream()
+                        .anyMatch(TextOutputDispositionEvent.class::isInstance),
+                "cancelled child text must not be classified as a successful terminal reply");
     }
 
     @Test
@@ -145,7 +168,10 @@ void normalCompletion_emitsPairedEvents() throws Exception {
         HarnessAgent harness = Mockito.mock(HarnessAgent.class);
         Mockito.when(harness.getDelegate()).thenReturn(delegate);
         Mockito.when(harness.call(any(Msg.class), any(RuntimeContext.class)))
-                .thenReturn(Mono.just(Msg.builder().name("child").textContent("done").build()));
+                .thenReturn(
+                        emitVisibleTextThen(
+                                Mono.just(
+                                        Msg.builder().name("child").textContent("done").build())));
 
         DefaultAgentManager manager =
                 new DefaultAgentManager(
@@ -164,6 +190,55 @@ void normalCompletion_emitsPairedEvents() throws Exception {
         assertEquals(1, emitter.count(AgentStartEvent.class), "expected one start event");
         assertEquals(1, emitter.count(AgentEndEvent.class), "expected one end event");
         assertReplyIdPair(emitter);
+        assertEquals(
+                AgentEndEvent.OUTCOME_SUCCESS,
+                emitter.first(AgentEndEvent.class)
+                        .getMetadata()
+                        .get(AgentEndEvent.METADATA_INVOCATION_OUTCOME));
+        TextOutputDispositionEvent terminal =
+                annotatedEvents(emitter).stream()
+                        .filter(TextOutputDispositionEvent.class::isInstance)
+                        .map(TextOutputDispositionEvent.class::cast)
+                        .findFirst()
+                        .orElseThrow();
+        assertEquals("child-model-reply", terminal.getReplyId());
+        assertEquals(TextOutputDisposition.TERMINAL, terminal.getDisposition());
+    }
+
+    @Test
+    @DisplayName("child error emits an abnormal end without terminal disposition")
+    void childError_doesNotEmitTerminalDisposition() {
+        ReActAgent delegate = Mockito.mock(ReActAgent.class);
+        HarnessAgent harness = Mockito.mock(HarnessAgent.class);
+        Mockito.when(harness.getDelegate()).thenReturn(delegate);
+        Mockito.when(harness.call(any(Msg.class), any(RuntimeContext.class)))
+                .thenReturn(
+                        emitVisibleTextThen(Mono.error(new IllegalStateException("child failed"))));
+
+        DefaultAgentManager manager =
+                new DefaultAgentManager(
+                        List.of(new SubagentEntry("harness_agent", "Harness child", rc -> harness)),
+                        null);
+        AgentSpawnTool tool = new AgentSpawnTool(manager, new NoopTaskRepository(), 0);
+        RuntimeContext parentCtx =
+                RuntimeContext.builder().sessionId("parent-session").userId("parent-user").build();
+        RecordingEmitter emitter = new RecordingEmitter();
+
+        String result =
+                tool.agentSpawn(parentCtx, null, "harness_agent", "work", null, 30, null)
+                        .contextWrite(ctx -> ctx.put(AgentEventEmitter.CONTEXT_KEY, emitter))
+                        .block();
+
+        assertTrue(result.contains("status: error"));
+        assertEquals(
+                AgentEndEvent.OUTCOME_ERROR,
+                emitter.first(AgentEndEvent.class)
+                        .getMetadata()
+                        .get(AgentEndEvent.METADATA_INVOCATION_OUTCOME));
+        assertFalse(
+                annotatedEvents(emitter).stream()
+                        .anyMatch(TextOutputDispositionEvent.class::isInstance),
+                "failed child text must not be classified as a successful terminal reply");
     }
 
     private static void assertReplyIdPair(RecordingEmitter emitter) {
@@ -173,6 +248,24 @@ private static void assertReplyIdPair(RecordingEmitter emitter) {
         assertEquals(start.getReplyId(), end.getReplyId());
     }
 
+    private static Mono emitVisibleTextThen(Mono terminal) {
+        return Mono.deferContextual(
+                context -> {
+                    AgentEventEmitter emitter =
+                            AgentEventEmitter.fromForwardingContext(context).orElseThrow();
+                    emitter.emit(new ModelCallStartEvent("child-model-reply"));
+                    emitter.emit(
+                            new TextBlockDeltaEvent("child-model-reply", "child-text", "working"));
+                    return terminal;
+                });
+    }
+
+    private static List annotatedEvents(RecordingEmitter emitter) {
+        return AgentEventStreams.withTextOutputDisposition(Flux.fromIterable(emitter.snapshot()))
+                .collectList()
+                .block();
+    }
+
     private static final class NoopTaskRepository implements TaskRepository {
         @Override
         public BackgroundTask getTask(RuntimeContext rc, String sessionId, String taskId) {

From 5d89e230ba86b49d8fad177f611415cfbce02742 Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Sun, 6 Sep 2026 18:41:13 +0800
Subject: [PATCH 14/22] =?UTF-8?q?fix(streaming):=20=E8=A1=A5=E9=BD=90?=
 =?UTF-8?q?=E5=AD=90=E6=99=BA=E8=83=BD=E4=BD=93=E7=BB=93=E6=9E=84=E5=8C=96?=
 =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=BA=8F=E5=88=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

透传子智能体生命周期、思考、文本处置、工具参数结果及任务标识。
修正 acting 中间件事件发布位置,避免事件遗漏或重复。

(cherry picked from commit 6af8e3b352d144f4b2dd8e5d1aec9b48a807d713)
---
 .../java/io/agentscope/core/ReActAgent.java   |   9 +-
 .../ReActAgentMiddlewareIntegrationTest.java  |  41 ++++
 .../strategy/SubagentEventConverter.java      | 226 +++++++++++++++---
 .../strategy/SubagentEventConverterTest.java  | 166 +++++++++++++
 4 files changed, 403 insertions(+), 39 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 c016735a78..44d395d909 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
@@ -2784,7 +2784,8 @@ private Mono acting(int iter) {
                                                         MiddlewareBase::onActing,
                                                         actingCore)
                                                 .apply(new ActingInput(toolCalls));
-                                return stream.doOnNext(
+                                return stream.doOnNext(this::publishEvent)
+                                        .doOnNext(
                                                 ev -> {
                                                     if (ev instanceof RequestStopEvent rs) {
                                                         actingStopRequested.compareAndSet(null, rs);
@@ -2924,8 +2925,7 @@ Flux actingStream(
                                         new RequestStopEvent(
                                                 "permission asking",
                                                 GenerateReason.PERMISSION_ASKING));
-                            })
-                    .doOnNext(this::publishEvent);
+                            });
         }
 
         /**
@@ -3885,7 +3885,8 @@ private Mono emitAllToolsDeniedThroughMiddleware(
                                     core)
                             .apply(new ActingInput(deniedToolCalls));
 
-            return stream.doOnNext(
+            return stream.doOnNext(this::publishEvent)
+                    .doOnNext(
                             ev -> {
                                 if (ev instanceof RequestStopEvent rs) {
                                     stopRef.compareAndSet(null, rs);
diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentMiddlewareIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentMiddlewareIntegrationTest.java
index 0b1b4844bc..dcb47ff900 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentMiddlewareIntegrationTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentMiddlewareIntegrationTest.java
@@ -30,6 +30,8 @@
 import io.agentscope.core.event.ModelCallStartEvent;
 import io.agentscope.core.event.TextBlockDeltaEvent;
 import io.agentscope.core.event.ToolCallStartEvent;
+import io.agentscope.core.event.ToolResultEndEvent;
+import io.agentscope.core.event.ToolResultStartEvent;
 import io.agentscope.core.message.ContentBlock;
 import io.agentscope.core.message.Msg;
 import io.agentscope.core.message.TextBlock;
@@ -327,6 +329,45 @@ public Flux onReasoning(
                 "core model events must not be published twice");
     }
 
+    @Test
+    void actingMiddlewareEventsAreForwardedExactlyOnce() {
+        MiddlewareBase hintMiddleware =
+                new MiddlewareBase() {
+                    @Override
+                    public Flux onActing(
+                            Agent agent,
+                            RuntimeContext ctx,
+                            ActingInput input,
+                            Function> next) {
+                        HintBlockEvent hint =
+                                new HintBlockEvent(
+                                        "reply-acting-hint",
+                                        "block-acting-hint",
+                                        "tool-middleware",
+                                        "completed");
+                        return next.apply(input).concatWithValues(hint);
+                    }
+                };
+        ToolThenFinalModel model = new ToolThenFinalModel();
+        Toolkit toolkit = new Toolkit();
+        toolkit.registerAgentTool(new LookupTool());
+        ReActAgent agent =
+                ReActAgent.builder()
+                        .name("asst")
+                        .sysPrompt("hello-system")
+                        .model(model)
+                        .toolkit(toolkit)
+                        .middleware(hintMiddleware)
+                        .build();
+
+        List events = agent.streamEvents(List.of()).collectList().block();
+
+        assertNotNull(events);
+        assertEquals(1, events.stream().filter(HintBlockEvent.class::isInstance).count());
+        assertEquals(1, events.stream().filter(ToolResultStartEvent.class::isInstance).count());
+        assertEquals(1, events.stream().filter(ToolResultEndEvent.class::isInstance).count());
+    }
+
     @Test
     void onionOrderingFollowsRegistrationForReplyHook() {
         List trace = new CopyOnWriteArrayList<>();
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java
index 9888ea8794..70fe89c18c 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java
@@ -18,13 +18,23 @@
 import io.agentscope.core.agui.event.AguiEvent;
 import io.agentscope.core.event.AgentEndEvent;
 import io.agentscope.core.event.AgentEvent;
+import io.agentscope.core.event.AgentResultEvent;
 import io.agentscope.core.event.AgentStartEvent;
 import io.agentscope.core.event.RequireUserConfirmEvent;
 import io.agentscope.core.event.TextBlockDeltaEvent;
+import io.agentscope.core.event.TextBlockEndEvent;
+import io.agentscope.core.event.TextBlockStartEvent;
+import io.agentscope.core.event.TextOutputDispositionEvent;
 import io.agentscope.core.event.ThinkingBlockDeltaEvent;
+import io.agentscope.core.event.ThinkingBlockEndEvent;
+import io.agentscope.core.event.ThinkingBlockStartEvent;
+import io.agentscope.core.event.ToolCallDeltaEvent;
 import io.agentscope.core.event.ToolCallEndEvent;
 import io.agentscope.core.event.ToolCallStartEvent;
+import io.agentscope.core.event.ToolResultDataDeltaEvent;
 import io.agentscope.core.event.ToolResultEndEvent;
+import io.agentscope.core.event.ToolResultStartEvent;
+import io.agentscope.core.event.ToolResultTextDeltaEvent;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
@@ -41,6 +51,8 @@ final class SubagentEventConverter implements AgentEventConverter {
     static final String NAME_THINKING = "subagent.thinking";
     static final String NAME_TOOL_CALL = "subagent.tool_call";
     static final String NAME_TOOL_RESULT = "subagent.tool_result";
+    static final String NAME_TEXT_DISPOSITION = "subagent.text_disposition";
+    static final String NAME_RESULT = "subagent.result";
     static final String NAME_CONFIRM = "subagent.require_confirm";
     static final String NAME_OTHER = "subagent.event";
 
@@ -54,83 +66,198 @@ public Set> eventTypes() {
 
     @Override
     public void convert(AgentEvent event, AguiStreamContext context) {
-        String source = event.getSource();
         if (event instanceof AgentStartEvent start) {
+            Map extra = new LinkedHashMap<>();
+            extra.put("sessionId", nullToEmpty(start.getSessionId()));
+            extra.put("name", nullToEmpty(start.getName()));
+            extra.put("role", nullToEmpty(start.getRole()));
+            putIfPresent(extra, "replyId", start.getReplyId());
+            context.emit(custom(context, NAME_LIFECYCLE, value(event, "AGENT_START", extra)));
+            return;
+        }
+        if (event instanceof AgentEndEvent end) {
+            Map extra = new LinkedHashMap<>();
+            putIfPresent(extra, "replyId", end.getReplyId());
+            putMetadataIfPresent(extra, event, AgentEndEvent.METADATA_INVOCATION_OUTCOME);
+            context.emit(custom(context, NAME_LIFECYCLE, value(event, "AGENT_END", extra)));
+            return;
+        }
+        if (event instanceof AgentResultEvent result) {
+            Map extra = new LinkedHashMap<>();
+            putIfPresent(extra, "result", result.getResult());
+            context.emit(custom(context, NAME_RESULT, value(event, "AGENT_RESULT", extra)));
+            return;
+        }
+        if (event instanceof TextOutputDispositionEvent disposition) {
+            Map extra = new LinkedHashMap<>();
+            putIfPresent(extra, "replyId", disposition.getReplyId());
+            extra.put("disposition", disposition.getDisposition().name());
+            if (disposition.getGenerateReason() != null) {
+                extra.put("generateReason", disposition.getGenerateReason().name());
+            }
             context.emit(
                     custom(
                             context,
-                            NAME_LIFECYCLE,
-                            value(
-                                    source,
-                                    "AGENT_START",
-                                    Map.of(
-                                            "name", nullToEmpty(start.getName()),
-                                            "replyId", nullToEmpty(start.getReplyId())))));
+                            NAME_TEXT_DISPOSITION,
+                            value(event, "TEXT_OUTPUT_DISPOSITION", extra)));
             return;
         }
-        if (event instanceof AgentEndEvent end) {
+        if (event instanceof TextBlockStartEvent textStart) {
             context.emit(
                     custom(
                             context,
-                            NAME_LIFECYCLE,
+                            NAME_TEXT,
                             value(
-                                    source,
-                                    "AGENT_END",
-                                    Map.of("replyId", nullToEmpty(end.getReplyId())))));
+                                    event,
+                                    "TEXT_BLOCK_START",
+                                    block(textStart.getReplyId(), textStart.getBlockId()))));
             return;
         }
         if (event instanceof TextBlockDeltaEvent text) {
+            Map extra = block(text.getReplyId(), text.getBlockId());
+            extra.put("delta", nullToEmpty(text.getDelta()));
+            context.emit(custom(context, NAME_TEXT, value(event, "TEXT_BLOCK_DELTA", extra)));
+            return;
+        }
+        if (event instanceof TextBlockEndEvent textEnd) {
             context.emit(
                     custom(
                             context,
                             NAME_TEXT,
                             value(
-                                    source,
-                                    "TEXT_BLOCK_DELTA",
-                                    Map.of("delta", nullToEmpty(text.getDelta())))));
+                                    event,
+                                    "TEXT_BLOCK_END",
+                                    block(textEnd.getReplyId(), textEnd.getBlockId()))));
+            return;
+        }
+        if (event instanceof ThinkingBlockStartEvent thinkingStart) {
+            context.emit(
+                    custom(
+                            context,
+                            NAME_THINKING,
+                            value(
+                                    event,
+                                    "THINKING_BLOCK_START",
+                                    block(
+                                            thinkingStart.getReplyId(),
+                                            thinkingStart.getBlockId()))));
             return;
         }
         if (event instanceof ThinkingBlockDeltaEvent thinking) {
+            Map extra = block(thinking.getReplyId(), thinking.getBlockId());
+            extra.put("delta", nullToEmpty(thinking.getDelta()));
+            context.emit(
+                    custom(context, NAME_THINKING, value(event, "THINKING_BLOCK_DELTA", extra)));
+            return;
+        }
+        if (event instanceof ThinkingBlockEndEvent thinkingEnd) {
             context.emit(
                     custom(
                             context,
                             NAME_THINKING,
                             value(
-                                    source,
-                                    "THINKING_BLOCK_DELTA",
-                                    Map.of("delta", nullToEmpty(thinking.getDelta())))));
+                                    event,
+                                    "THINKING_BLOCK_END",
+                                    block(thinkingEnd.getReplyId(), thinkingEnd.getBlockId()))));
             return;
         }
         if (event instanceof ToolCallStartEvent toolStart) {
-            Map extra = new LinkedHashMap<>();
-            extra.put("toolCallId", nullToEmpty(toolStart.getToolCallId()));
-            extra.put("toolName", nullToEmpty(toolStart.getToolCallName()));
-            context.emit(custom(context, NAME_TOOL_CALL, value(source, "TOOL_CALL_START", extra)));
+            context.emit(
+                    custom(
+                            context,
+                            NAME_TOOL_CALL,
+                            value(
+                                    event,
+                                    "TOOL_CALL_START",
+                                    tool(
+                                            toolStart.getReplyId(),
+                                            toolStart.getToolCallId(),
+                                            toolStart.getToolCallName()))));
+            return;
+        }
+        if (event instanceof ToolCallDeltaEvent toolDelta) {
+            Map extra =
+                    tool(
+                            toolDelta.getReplyId(),
+                            toolDelta.getToolCallId(),
+                            toolDelta.getToolCallName());
+            extra.put("argumentsDelta", nullToEmpty(toolDelta.getDelta()));
+            context.emit(custom(context, NAME_TOOL_CALL, value(event, "TOOL_CALL_DELTA", extra)));
             return;
         }
         if (event instanceof ToolCallEndEvent toolEnd) {
-            Map extra = new LinkedHashMap<>();
-            extra.put("toolCallId", nullToEmpty(toolEnd.getToolCallId()));
-            extra.put("toolName", nullToEmpty(toolEnd.getToolCallName()));
-            context.emit(custom(context, NAME_TOOL_CALL, value(source, "TOOL_CALL_END", extra)));
+            context.emit(
+                    custom(
+                            context,
+                            NAME_TOOL_CALL,
+                            value(
+                                    event,
+                                    "TOOL_CALL_END",
+                                    tool(
+                                            toolEnd.getReplyId(),
+                                            toolEnd.getToolCallId(),
+                                            toolEnd.getToolCallName()))));
+            return;
+        }
+        if (event instanceof ToolResultStartEvent toolResultStart) {
+            context.emit(
+                    custom(
+                            context,
+                            NAME_TOOL_RESULT,
+                            value(
+                                    event,
+                                    "TOOL_RESULT_START",
+                                    tool(
+                                            toolResultStart.getReplyId(),
+                                            toolResultStart.getToolCallId(),
+                                            toolResultStart.getToolCallName()))));
+            return;
+        }
+        if (event instanceof ToolResultTextDeltaEvent textDelta) {
+            Map extra =
+                    tool(
+                            textDelta.getReplyId(),
+                            textDelta.getToolCallId(),
+                            textDelta.getToolCallName());
+            extra.put("delta", nullToEmpty(textDelta.getDelta()));
+            context.emit(
+                    custom(
+                            context,
+                            NAME_TOOL_RESULT,
+                            value(event, "TOOL_RESULT_TEXT_DELTA", extra)));
+            return;
+        }
+        if (event instanceof ToolResultDataDeltaEvent dataDelta) {
+            Map extra =
+                    tool(
+                            dataDelta.getReplyId(),
+                            dataDelta.getToolCallId(),
+                            dataDelta.getToolCallName());
+            extra.put("data", dataDelta.getData());
+            context.emit(
+                    custom(
+                            context,
+                            NAME_TOOL_RESULT,
+                            value(event, "TOOL_RESULT_DATA_DELTA", extra)));
             return;
         }
         if (event instanceof ToolResultEndEvent toolResult) {
-            Map extra = new LinkedHashMap<>();
-            extra.put("toolCallId", nullToEmpty(toolResult.getToolCallId()));
-            extra.put("toolName", nullToEmpty(toolResult.getToolCallName()));
+            Map extra =
+                    tool(
+                            toolResult.getReplyId(),
+                            toolResult.getToolCallId(),
+                            toolResult.getToolCallName());
             if (toolResult.getState() != null) {
                 extra.put("state", toolResult.getState().name());
             }
-            context.emit(
-                    custom(context, NAME_TOOL_RESULT, value(source, "TOOL_RESULT_END", extra)));
+            context.emit(custom(context, NAME_TOOL_RESULT, value(event, "TOOL_RESULT_END", extra)));
             return;
         }
         if (event instanceof RequireUserConfirmEvent confirm) {
             Map extra = new LinkedHashMap<>();
             extra.put("toolCallCount", confirm.getToolCalls().size());
             context.emit(
-                    custom(context, NAME_CONFIRM, value(source, "REQUIRE_USER_CONFIRM", extra)));
+                    custom(context, NAME_CONFIRM, value(event, "REQUIRE_USER_CONFIRM", extra)));
             return;
         }
         // Unknown typed events: prefer Raw (already carries source) over opaque custom.
@@ -143,9 +270,10 @@ private static AguiEvent.Custom custom(
     }
 
     private static Map value(
-            String source, String type, Map extra) {
+            AgentEvent event, String type, Map extra) {
         Map value = new LinkedHashMap<>();
-        value.put("source", source);
+        value.put("source", event.getSource());
+        putMetadataIfPresent(value, event, AgentEvent.METADATA_TASK_ID);
         value.put("type", type);
         if (extra != null) {
             value.putAll(extra);
@@ -153,6 +281,34 @@ private static Map value(
         return value;
     }
 
+    private static Map block(String replyId, String blockId) {
+        Map value = new LinkedHashMap<>();
+        putIfPresent(value, "replyId", replyId);
+        putIfPresent(value, "blockId", blockId);
+        return value;
+    }
+
+    private static Map tool(String replyId, String toolCallId, String toolName) {
+        Map value = new LinkedHashMap<>();
+        putIfPresent(value, "replyId", replyId);
+        value.put("toolCallId", nullToEmpty(toolCallId));
+        value.put("toolName", nullToEmpty(toolName));
+        return value;
+    }
+
+    private static void putMetadataIfPresent(
+            Map target, AgentEvent event, String key) {
+        if (event.getMetadata() != null) {
+            putIfPresent(target, key, event.getMetadata().get(key));
+        }
+    }
+
+    private static void putIfPresent(Map target, String key, Object value) {
+        if (value != null) {
+            target.put(key, value);
+        }
+    }
+
     private static String nullToEmpty(String s) {
         return s != null ? s : "";
     }
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java
index 4d1df67a57..c1555140a7 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java
@@ -16,14 +16,37 @@
 package io.agentscope.core.agui.adapter.strategy;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import io.agentscope.core.agui.adapter.AguiAdapterConfig;
 import io.agentscope.core.agui.event.AguiEvent;
 import io.agentscope.core.event.AgentEndEvent;
+import io.agentscope.core.event.AgentEvent;
+import io.agentscope.core.event.AgentResultEvent;
 import io.agentscope.core.event.AgentStartEvent;
 import io.agentscope.core.event.TextBlockDeltaEvent;
+import io.agentscope.core.event.TextBlockEndEvent;
+import io.agentscope.core.event.TextBlockStartEvent;
+import io.agentscope.core.event.TextOutputDisposition;
+import io.agentscope.core.event.TextOutputDispositionEvent;
+import io.agentscope.core.event.ThinkingBlockDeltaEvent;
+import io.agentscope.core.event.ThinkingBlockEndEvent;
+import io.agentscope.core.event.ThinkingBlockStartEvent;
+import io.agentscope.core.event.ToolCallDeltaEvent;
+import io.agentscope.core.event.ToolCallEndEvent;
+import io.agentscope.core.event.ToolCallStartEvent;
+import io.agentscope.core.event.ToolResultDataDeltaEvent;
+import io.agentscope.core.event.ToolResultEndEvent;
+import io.agentscope.core.event.ToolResultStartEvent;
+import io.agentscope.core.event.ToolResultTextDeltaEvent;
+import io.agentscope.core.message.GenerateReason;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultState;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import org.junit.jupiter.api.Test;
@@ -75,6 +98,144 @@ void subagentEventsDowngradeToCustomByDefault() {
         assertEquals(SubagentEventConverter.NAME_TEXT, textCustom.name());
     }
 
+    @Test
+    void completeSubagentSequencePreservesOrderIdentityAndStructuredPayloads() {
+        AgentEventConverterRegistry registry = new AgentEventConverterRegistry();
+        AguiStreamContext context =
+                new AguiStreamContext("thread-1", "run-1", AguiAdapterConfig.defaultConfig());
+        Msg result =
+                Msg.builder()
+                        .id("child-result")
+                        .role(MsgRole.ASSISTANT)
+                        .textContent("final answer")
+                        .generateReason(GenerateReason.MODEL_STOP)
+                        .build();
+        List sourceEvents =
+                List.of(
+                        new AgentStartEvent("child-session", "reply-1", "researcher"),
+                        new ThinkingBlockStartEvent("reply-1", "thinking-1"),
+                        new ThinkingBlockDeltaEvent("reply-1", "thinking-1", "analyzing"),
+                        new ThinkingBlockEndEvent("reply-1", "thinking-1"),
+                        new TextBlockStartEvent("reply-1", "text-1"),
+                        new TextBlockDeltaEvent("reply-1", "text-1", "checking sources"),
+                        new TextBlockEndEvent("reply-1", "text-1"),
+                        new TextOutputDispositionEvent(
+                                "reply-1", TextOutputDisposition.INTERMEDIATE, null),
+                        new ToolCallStartEvent("reply-1", "tool-1", "search"),
+                        new ToolCallStartEvent("reply-1", "tool-2", "lookup"),
+                        new ToolCallDeltaEvent("reply-1", "tool-1", "search", "{\"q\":"),
+                        new ToolCallDeltaEvent("reply-1", "tool-2", "lookup", "{\"id\":"),
+                        new ToolCallEndEvent("reply-1", "tool-2", "lookup"),
+                        new ToolCallEndEvent("reply-1", "tool-1", "search"),
+                        new ToolResultStartEvent("reply-1", "tool-2", "lookup"),
+                        new ToolResultStartEvent("reply-1", "tool-1", "search"),
+                        new ToolResultTextDeltaEvent(
+                                "reply-1", "tool-2", "lookup", "lookup result"),
+                        new ToolResultEndEvent(
+                                "reply-1", "tool-2", "lookup", ToolResultState.SUCCESS),
+                        new ToolResultDataDeltaEvent(
+                                "reply-1",
+                                "tool-1",
+                                "search",
+                                TextBlock.builder().text("search data").build()),
+                        new ToolResultEndEvent(
+                                "reply-1", "tool-1", "search", ToolResultState.SUCCESS),
+                        new TextBlockStartEvent("reply-2", "text-2"),
+                        new TextBlockDeltaEvent("reply-2", "text-2", "final answer"),
+                        new TextBlockEndEvent("reply-2", "text-2"),
+                        new AgentResultEvent(result),
+                        new TextOutputDispositionEvent(
+                                "reply-2",
+                                TextOutputDisposition.TERMINAL,
+                                GenerateReason.MODEL_STOP),
+                        new AgentEndEvent("reply-2"));
+
+        List converted = new ArrayList<>();
+        for (AgentEvent event : sourceEvents) {
+            event.withSource("parent/researcher")
+                    .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-42");
+            converted.addAll(registry.convert(event, context));
+        }
+
+        assertEquals(sourceEvents.size(), converted.size());
+        List customEvents =
+                converted.stream()
+                        .map(event -> assertInstanceOf(AguiEvent.Custom.class, event))
+                        .toList();
+        assertEquals(
+                List.of(
+                        "subagent.lifecycle:AGENT_START",
+                        "subagent.thinking:THINKING_BLOCK_START",
+                        "subagent.thinking:THINKING_BLOCK_DELTA",
+                        "subagent.thinking:THINKING_BLOCK_END",
+                        "subagent.text:TEXT_BLOCK_START",
+                        "subagent.text:TEXT_BLOCK_DELTA",
+                        "subagent.text:TEXT_BLOCK_END",
+                        "subagent.text_disposition:TEXT_OUTPUT_DISPOSITION",
+                        "subagent.tool_call:TOOL_CALL_START",
+                        "subagent.tool_call:TOOL_CALL_START",
+                        "subagent.tool_call:TOOL_CALL_DELTA",
+                        "subagent.tool_call:TOOL_CALL_DELTA",
+                        "subagent.tool_call:TOOL_CALL_END",
+                        "subagent.tool_call:TOOL_CALL_END",
+                        "subagent.tool_result:TOOL_RESULT_START",
+                        "subagent.tool_result:TOOL_RESULT_START",
+                        "subagent.tool_result:TOOL_RESULT_TEXT_DELTA",
+                        "subagent.tool_result:TOOL_RESULT_END",
+                        "subagent.tool_result:TOOL_RESULT_DATA_DELTA",
+                        "subagent.tool_result:TOOL_RESULT_END",
+                        "subagent.text:TEXT_BLOCK_START",
+                        "subagent.text:TEXT_BLOCK_DELTA",
+                        "subagent.text:TEXT_BLOCK_END",
+                        "subagent.result:AGENT_RESULT",
+                        "subagent.text_disposition:TEXT_OUTPUT_DISPOSITION",
+                        "subagent.lifecycle:AGENT_END"),
+                customEvents.stream()
+                        .map(event -> event.name() + ":" + value(event).get("type"))
+                        .toList());
+        assertTrue(
+                customEvents.stream()
+                        .allMatch(
+                                event ->
+                                        "parent/researcher".equals(value(event).get("source"))
+                                                && "task-42".equals(value(event).get("taskId"))));
+
+        Map toolArgs = value(customEvents.get(10));
+        assertEquals("reply-1", toolArgs.get("replyId"));
+        assertEquals("tool-1", toolArgs.get("toolCallId"));
+        assertEquals("{\"q\":", toolArgs.get("argumentsDelta"));
+
+        Map textResult = value(customEvents.get(16));
+        assertEquals("lookup result", textResult.get("delta"));
+        Map dataResult = value(customEvents.get(18));
+        assertInstanceOf(TextBlock.class, dataResult.get("data"));
+
+        Map agentResult = value(customEvents.get(23));
+        assertEquals(result, agentResult.get("result"));
+        Map terminal = value(customEvents.get(24));
+        assertEquals("TERMINAL", terminal.get("disposition"));
+        assertEquals("MODEL_STOP", terminal.get("generateReason"));
+    }
+
+    @Test
+    void nullSubagentResultDoesNotInterruptEventConversion() {
+        AgentEventConverterRegistry registry = new AgentEventConverterRegistry();
+        AguiStreamContext context =
+                new AguiStreamContext("thread-1", "run-1", AguiAdapterConfig.defaultConfig());
+        AgentResultEvent event = new AgentResultEvent(null);
+        event.withSource("parent/researcher")
+                .withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-42");
+
+        List converted = registry.convert(event, context);
+
+        AguiEvent.Custom custom = assertInstanceOf(AguiEvent.Custom.class, converted.get(0));
+        assertEquals(SubagentEventConverter.NAME_RESULT, custom.name());
+        assertEquals("AGENT_RESULT", value(custom).get("type"));
+        assertEquals("parent/researcher", value(custom).get("source"));
+        assertEquals("task-42", value(custom).get("taskId"));
+        assertFalse(value(custom).containsKey("result"));
+    }
+
     @Test
     void nativeModeKeepsLegacyBehavior() {
         AgentEventConverterRegistry registry =
@@ -100,4 +261,9 @@ void configFlagDefaultsFalse() {
                         .build()
                         .isEmitSubagentEventsAsNative());
     }
+
+    @SuppressWarnings("unchecked")
+    private static Map value(AguiEvent.Custom event) {
+        return (Map) event.value();
+    }
 }

From 785fc9426a06badfcb88c92ff6651a0fb87decbe Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Sun, 6 Sep 2026 20:55:55 +0800
Subject: [PATCH 15/22] =?UTF-8?q?fix(streaming):=20=E4=BF=AE=E5=A4=8D?=
 =?UTF-8?q?=E6=9D=83=E5=A8=81=E7=BB=93=E6=9E=9C=E4=B8=8E=E9=A2=84=E8=A7=88?=
 =?UTF-8?q?=E5=B9=B6=E5=8F=91=E8=BE=B9=E7=95=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

补齐本地子智能体的 AgentResultEvent 转发,避免只收到结束事件而缺少权威结果。
AG-UI 仅按当前运行实际生成的分段消息清理临时文本,避免误删合法历史消息。
Web 预览总线不再重放订阅前事件,并串行化并发发送以避免丢帧。

(cherry picked from commit 76413d62257d6e593add8d0364703372379aee2f)
---
 .../agui/adapter/strategy/AguiStreamContext.java  | 11 ++++-------
 .../core/agui/adapter/AguiAgentAdapterV2Test.java | 14 +++++++-------
 .../harness/agent/tool/AgentSpawnTool.java        | 12 +++++++++++-
 .../HarnessAgentSubagentStreamEventsTest.java     | 12 ++++++++++++
 .../web/managed/SessionEventPreviewBus.java       | 15 ++++-----------
 5 files changed, 38 insertions(+), 26 deletions(-)

diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
index a6e7923481..4aa41e4d0d 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
@@ -44,7 +44,6 @@
 import java.util.Set;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
-import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -57,8 +56,6 @@ public class AguiStreamContext {
                     GenerateReason.MODEL_STOP,
                     GenerateReason.STRUCTURED_OUTPUT,
                     GenerateReason.MAX_ITERATIONS);
-    private static final Pattern TEXT_SEGMENT_ID = Pattern.compile("^.+:text:\\d+$");
-
     private final String threadId;
     private final String runId;
     private final AguiAdapterConfig config;
@@ -256,7 +253,7 @@ public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) {
                 authoritativeMessages != null && !authoritativeMessages.isEmpty();
         if (hasAuthoritativeMessages) {
             for (Msg message : authoritativeMessages) {
-                if (message != null && !isTextSegmentId(message.getId())) {
+                if (message != null && !isGeneratedTextSegmentId(message.getId())) {
                     messagesById.put(message.getId(), messageConverter.toAguiMessage(message));
                 }
             }
@@ -264,7 +261,7 @@ public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) {
         if (runInput != null) {
             for (AguiMessage message : runInput.getMessages()) {
                 if (message != null
-                        && !isTextSegmentId(message.getId())
+                        && !isGeneratedTextSegmentId(message.getId())
                         && (!hasAuthoritativeMessages
                                 || messagesById.containsKey(message.getId()))) {
                     messagesById.put(message.getId(), message);
@@ -494,8 +491,8 @@ private void emitTextOutputDisposition(String replyId, TextOutputDispositionStat
                         Collections.unmodifiableMap(value)));
     }
 
-    private static boolean isTextSegmentId(String messageId) {
-        return messageId != null && TEXT_SEGMENT_ID.matcher(messageId).matches();
+    private boolean isGeneratedTextSegmentId(String messageId) {
+        return messageId != null && startedTextMessages.contains(messageId);
     }
 
     private static String normalizeToolCallName(String toolCallName) {
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
index ba5d5ee0d0..87d2d3c1a0 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
@@ -703,21 +703,21 @@ void testFinalSnapshotFallsBackToOriginalInputWithoutAgentState() {
         }
 
         @Test
-        void testFinalSnapshotExcludesOnlyReservedTextSegmentIds() {
+        void testFinalSnapshotExcludesOnlySegmentsCreatedByCurrentRun() {
             Msg user = Msg.builder().id("session-user").role(MsgRole.USER).textContent("q").build();
-            Msg liveSegment =
+            Msg generatedSegment =
                     AssistantMessage.builder()
-                            .id("reply-preview:text:7")
+                            .id("reply-final:text:0")
                             .content(TextBlock.builder().text("preview").build())
                             .build();
-            Msg ordinaryColonId =
+            Msg legitimatePatternId =
                     AssistantMessage.builder()
-                            .id("reply-preview:text:final")
+                            .id("order:text:0")
                             .content(TextBlock.builder().text("kept").build())
                             .build();
             AgentState state =
                     AgentState.builder()
-                            .context(List.of(user, liveSegment, ordinaryColonId))
+                            .context(List.of(user, generatedSegment, legitimatePatternId))
                             .build();
             RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build();
 
@@ -725,7 +725,7 @@ void testFinalSnapshotExcludesOnlyReservedTextSegmentIds() {
                     runTerminalDisposition(GenerateReason.MODEL_STOP, callerContext);
 
             assertEquals(
-                    List.of("session-user", "reply-preview:text:final", "reply-final"),
+                    List.of("session-user", "order:text:0", "reply-final"),
                     messageIds(snapshot(events)));
         }
 
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 e23e8fa1bc..447a596cab 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
@@ -25,6 +25,7 @@
 import io.agentscope.core.event.AgentEndEvent;
 import io.agentscope.core.event.AgentEvent;
 import io.agentscope.core.event.AgentEventEmitter;
+import io.agentscope.core.event.AgentResultEvent;
 import io.agentscope.core.event.AgentStartEvent;
 import io.agentscope.core.event.SubagentExposedEvent;
 import io.agentscope.core.message.Msg;
@@ -820,7 +821,16 @@ private Mono execLocalSync(
                                 // Emit before success or error reaches the parent, which may
                                 // otherwise complete its event sink before doFinally runs.
                                 .doOnSuccess(
-                                        ignored -> emitEnd.accept(AgentEndEvent.OUTCOME_SUCCESS))
+                                        result -> {
+                                            if (result != null) {
+                                                parentEmitter.emit(
+                                                        tagForwardedEvent(
+                                                                new AgentResultEvent(result),
+                                                                sourcePath,
+                                                                taskId));
+                                            }
+                                            emitEnd.accept(AgentEndEvent.OUTCOME_SUCCESS);
+                                        })
                                 .doOnError(ignored -> emitEnd.accept(AgentEndEvent.OUTCOME_ERROR))
                                 // Preserve best-effort cancellation signaling without emitting a
                                 // duplicate if cancellation races with normal termination.
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 f05df63257..83119ec025 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
@@ -488,6 +488,18 @@ void streamEvents_childAgentStartAndEndEmittedWithSource() throws Exception {
                         .collect(Collectors.toList());
         assertFalse(childEnds.isEmpty(), "expected child AGENT_END with source");
         assertTrue(childEnds.get(0).getSource().contains(childId));
+
+        List childResults =
+                events.stream()
+                        .filter(
+                                e ->
+                                        e.getType() == AgentEventType.AGENT_RESULT
+                                                && e.getSource() != null)
+                        .collect(Collectors.toList());
+        assertEquals(1, childResults.size(), "expected one authoritative child AGENT_RESULT");
+        assertTrue(
+                events.indexOf(childResults.get(0)) < events.indexOf(childEnds.get(0)),
+                "child AGENT_RESULT must be emitted before child AGENT_END");
     }
 
     // -----------------------------------------------------------------
diff --git a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java
index a6afd4ccff..a0aad8f550 100644
--- a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java
+++ b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java
@@ -18,7 +18,6 @@
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
 import org.springframework.stereotype.Component;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Sinks;
@@ -30,8 +29,7 @@
 @Component
 public class SessionEventPreviewBus {
 
-    private final ConcurrentHashMap> sinks =
-            new ConcurrentHashMap<>();
+    private final Sinks.Many sink = Sinks.many().multicast().directBestEffort();
 
     /** Emits an {@code event_start} frame for a forthcoming persisted type. */
     public void emitStart(String sessionId, String targetType, String eventId) {
@@ -64,18 +62,13 @@ public void emitFrame(
     }
 
     public Flux subscribe(String sessionId) {
-        return sinkFor(sessionId).asFlux();
+        return sink.asFlux().filter(dto -> sessionId.equals(dto.sessionId()));
     }
 
-    private void emit(String sessionId, String type, Map payload) {
+    private synchronized void emit(String sessionId, String type, Map payload) {
         SessionEventDto dto =
                 new SessionEventDto(
                         null, sessionId, -1L, type, payload, null, System.currentTimeMillis());
-        sinkFor(sessionId).tryEmitNext(dto);
-    }
-
-    private Sinks.Many sinkFor(String sessionId) {
-        return sinks.computeIfAbsent(
-                sessionId, ignored -> Sinks.many().multicast().onBackpressureBuffer(512, false));
+        sink.tryEmitNext(dto);
     }
 }

From 19b32e5622f0cbf06429c5f6804c145abccc3123 Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Sat, 12 Sep 2026 07:43:44 +0800
Subject: [PATCH 16/22] =?UTF-8?q?fix(agui):=20=E5=A4=8D=E7=94=A8=E5=AE=98?=
 =?UTF-8?q?=E6=96=B9=E6=96=87=E6=9C=AC=E6=AE=B5=E6=B6=88=E6=81=AF=20ID=20?=
 =?UTF-8?q?=E5=B9=B6=E5=AF=B9=E9=BD=90=E5=A4=84=E7=BD=AE=E4=BA=8B=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

上游 #3010 之后文本消息 ID 为 replyId-blockId(如 reply-1-text-1),本分支的 AguiStreamContext 仍把传入值当 replyId 再拼 :text:N,导致处置事件 messageIds 与流式消息 ID 不一致。

改为直接复用官方 messageId,并从段 ID 反推所属 replyId 维持处置分组;保留按本轮实际生成段判定快照排除的语义,同步更新测试期望与 README 说明。

(cherry picked from commit 42ad83821dd76259e1559fc5305f55759ca3ec4a)
---
 .../agentscope-extensions-agui/README.md      | 10 +--
 .../adapter/strategy/AguiStreamContext.java   | 63 ++++++++++---------
 .../agui/adapter/AguiAgentAdapterV2Test.java  | 40 ++++++------
 3 files changed, 60 insertions(+), 53 deletions(-)

diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md
index f6428efd36..2081e0c420 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/README.md
@@ -19,7 +19,8 @@ When enabled, the adapter derives `TextOutputDispositionEvent` values from the a
 emits an AG-UI `CUSTOM` event named `agentscope.text_output.disposition`. Its value contains:
 
 - `replyId`: the AgentScope reply whose text lifecycle changed;
-- `messageIds`: all AG-UI text segment IDs associated with that reply;
+- `messageIds`: all AG-UI text segment IDs associated with that reply, using the stream's own
+  `replyId-blockId` message IDs (for example `reply-1-text-1`);
 - `disposition`: `INTERMEDIATE` or `TERMINAL`;
 - `generateReason`: the generation reason when one is available.
 
@@ -32,6 +33,7 @@ containing the authoritative result and available conversation state. Consumers
 snapshot to reconcile or replace streamed text. No snapshot is emitted for a pending interrupt or
 for generation reasons that do not represent an ordinary completed answer.
 
-Leaving `textOutputDispositionEnabled` unset (or setting it to `false`) preserves the legacy
-message ID (`replyId`) and event sequence and emits neither disposition `CUSTOM` events nor final
-`MESSAGES_SNAPSHOT` calibration events.
+Leaving `textOutputDispositionEnabled` unset (or setting it to `false`) preserves the existing
+`replyId-blockId` message ID and event sequence unchanged, and emits neither disposition `CUSTOM`
+events nor final `MESSAGES_SNAPSHOT` calibration events. Enabling the flag only adds the
+disposition `CUSTOM` events and snapshot calibration on top of those same message IDs.
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
index 4aa41e4d0d..9f10477037 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
@@ -44,6 +44,8 @@
 import java.util.Set;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -56,6 +58,15 @@ public class AguiStreamContext {
                     GenerateReason.MODEL_STOP,
                     GenerateReason.STRUCTURED_OUTPUT,
                     GenerateReason.MAX_ITERATIONS);
+
+    /**
+     * Matches the reserved live text-segment message ids produced by the AG-UI text/reasoning
+     * converters ({@code -text[-N]}, {@code -thinking[-N]}). The reply id itself
+     * is recovered from the first capture group.
+     */
+    private static final Pattern TEXT_SEGMENT_ID =
+            Pattern.compile("^(.+)-(?:text|thinking|reasoning)(?:-\\d+)?$");
+
     private final String threadId;
     private final String runId;
     private final AguiAdapterConfig config;
@@ -175,9 +186,15 @@ TokenUsageAccumulator getTokenUsageAccumulator() {
         return tokenUsageAccumulator;
     }
 
-    public void startTextMessage(String replyId) {
-        String messageId = resolveTextMessageId(replyId);
+    public void startTextMessage(String messageId) {
+        String replyId = replyIdOf(messageId);
         if (startedTextMessages.add(messageId)) {
+            if (config.isTextOutputDispositionEnabled()) {
+                textMessageIdsByReply
+                        .computeIfAbsent(replyId, ignored -> new ArrayList<>())
+                        .add(messageId);
+                activeTextMessageIdsByReply.put(replyId, messageId);
+            }
             emit(new AguiEvent.TextMessageStart(threadId, runId, messageId, "assistant"));
             emitRememberedTextOutputDisposition(replyId);
         }
@@ -185,9 +202,9 @@ public void startTextMessage(String replyId) {
         currentTextMessageId = messageId;
     }
 
-    public void appendTextDelta(String replyId, String delta) {
+    public void appendTextDelta(String messageId, String delta) {
         if (delta != null && !delta.isEmpty()) {
-            startTextMessage(replyId);
+            startTextMessage(messageId);
             emit(new AguiEvent.TextMessageContent(threadId, runId, currentTextMessageId, delta));
         }
     }
@@ -196,18 +213,10 @@ public void closeActiveTextMessage() {
         if (currentTextMessageId == null) {
             return;
         }
-        closeResolvedTextMessage(currentTextReplyId, currentTextMessageId);
+        closeTextMessage(currentTextMessageId);
     }
 
-    public void closeTextMessage(String replyId) {
-        String messageId =
-                config.isTextOutputDispositionEnabled()
-                        ? activeTextMessageIdsByReply.get(replyId)
-                        : replyId;
-        closeResolvedTextMessage(replyId, messageId);
-    }
-
-    private void closeResolvedTextMessage(String replyId, String messageId) {
+    public void closeTextMessage(String messageId) {
         if (messageId == null
                 || !startedTextMessages.contains(messageId)
                 || endedTextMessages.contains(messageId)) {
@@ -219,7 +228,7 @@ private void closeResolvedTextMessage(String replyId, String messageId) {
             currentTextReplyId = null;
         }
         if (config.isTextOutputDispositionEnabled()) {
-            activeTextMessageIdsByReply.remove(replyId, messageId);
+            activeTextMessageIdsByReply.remove(replyIdOf(messageId), messageId);
         }
         emit(new AguiEvent.TextMessageEnd(threadId, runId, messageId));
     }
@@ -452,20 +461,16 @@ private StringBuilder toolResultBuffer(String toolCallId) {
         return toolResultContent.computeIfAbsent(toolCallId, ignored -> new StringBuilder());
     }
 
-    private String resolveTextMessageId(String replyId) {
-        if (!config.isTextOutputDispositionEnabled()) {
-            return replyId;
-        }
-        return activeTextMessageIdsByReply.computeIfAbsent(
-                replyId,
-                key -> {
-                    List messageIds =
-                            textMessageIdsByReply.computeIfAbsent(
-                                    key, ignored -> new ArrayList<>());
-                    String messageId = key + ":text:" + messageIds.size();
-                    messageIds.add(messageId);
-                    return messageId;
-                });
+    /**
+     * Recovers the owning reply id from a live text-segment message id. Ids that are not segment ids
+     * (for example a single-block reply id) are their own reply id.
+     */
+    private static String replyIdOf(String messageId) {
+        if (messageId == null) {
+            return null;
+        }
+        Matcher matcher = TEXT_SEGMENT_ID.matcher(messageId);
+        return matcher.matches() ? matcher.group(1) : messageId;
     }
 
     private void emitRememberedTextOutputDisposition(String replyId) {
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
index 87d2d3c1a0..573df7f604 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
@@ -351,7 +351,7 @@ void testTextOutputDispositionRemainsDisabledWithoutChangingLegacySequenceOrMess
                             AguiEventType.RUN_FINISHED),
                     types(events));
             assertEquals(
-                    List.of("reply-legacy", "reply-legacy", "reply-legacy"),
+                    List.of("reply-legacy-text-1", "reply-legacy-text-1", "reply-legacy-text-1"),
                     events.stream()
                             .filter(
                                     event ->
@@ -389,12 +389,12 @@ void testEnabledDispositionUsesSegmentIdsAndEmitsOneCustomEventWithoutReasoning(
 
             assertEquals(
                     List.of(
-                            "reply-1:text:0",
-                            "reply-1:text:0",
-                            "reply-1:text:0",
-                            "reply-1:text:1",
-                            "reply-1:text:1",
-                            "reply-1:text:1"),
+                            "reply-1-text-1",
+                            "reply-1-text-1",
+                            "reply-1-text-1",
+                            "reply-1-text-2",
+                            "reply-1-text-2",
+                            "reply-1-text-2"),
                     events.stream()
                             .filter(
                                     event ->
@@ -424,7 +424,7 @@ void testEnabledDispositionUsesSegmentIdsAndEmitsOneCustomEventWithoutReasoning(
                             "replyId",
                             "reply-1",
                             "messageIds",
-                            List.of("reply-1:text:0", "reply-1:text:1"),
+                            List.of("reply-1-text-1", "reply-1-text-2"),
                             "disposition",
                             "INTERMEDIATE"),
                     customValue(disposition).entrySet().stream()
@@ -456,12 +456,12 @@ void testToolFollowupSegmentInheritsSingleIntermediateDisposition() {
 
             assertEquals(
                     List.of(
-                            "reply-1:text:0",
-                            "reply-1:text:0",
-                            "reply-1:text:0",
-                            "reply-1:text:1",
-                            "reply-1:text:1",
-                            "reply-1:text:1"),
+                            "reply-1-text-1",
+                            "reply-1-text-1",
+                            "reply-1-text-1",
+                            "reply-1-text-2",
+                            "reply-1-text-2",
+                            "reply-1-text-2"),
                     events.stream()
                             .filter(
                                     event ->
@@ -490,9 +490,9 @@ void testToolFollowupSegmentInheritsSingleIntermediateDisposition() {
                             .toList();
             assertEquals(2, dispositions.size());
             assertEquals(
-                    List.of("reply-1:text:0"), customValue(dispositions.get(0)).get("messageIds"));
+                    List.of("reply-1-text-1"), customValue(dispositions.get(0)).get("messageIds"));
             assertEquals(
-                    List.of("reply-1:text:0", "reply-1:text:1"),
+                    List.of("reply-1-text-1", "reply-1-text-2"),
                     customValue(dispositions.get(1)).get("messageIds"));
             assertEquals("INTERMEDIATE", customValue(dispositions.get(1)).get("disposition"));
             assertFalse(
@@ -673,7 +673,7 @@ void testFinalSnapshotUsesSessionMessagesAndResultDeduplicatedById() {
             assertFalse(
                     snapshot.messages().stream()
                             .map(AguiMessage::getId)
-                            .anyMatch(id -> id.contains(":text:")));
+                            .anyMatch("reply-final-text-live"::equals));
         }
 
         @Test
@@ -707,12 +707,12 @@ void testFinalSnapshotExcludesOnlySegmentsCreatedByCurrentRun() {
             Msg user = Msg.builder().id("session-user").role(MsgRole.USER).textContent("q").build();
             Msg generatedSegment =
                     AssistantMessage.builder()
-                            .id("reply-final:text:0")
+                            .id("reply-final-text-live")
                             .content(TextBlock.builder().text("preview").build())
                             .build();
             Msg legitimatePatternId =
                     AssistantMessage.builder()
-                            .id("order:text:0")
+                            .id("order-text-1")
                             .content(TextBlock.builder().text("kept").build())
                             .build();
             AgentState state =
@@ -725,7 +725,7 @@ void testFinalSnapshotExcludesOnlySegmentsCreatedByCurrentRun() {
                     runTerminalDisposition(GenerateReason.MODEL_STOP, callerContext);
 
             assertEquals(
-                    List.of("session-user", "order:text:0", "reply-final"),
+                    List.of("session-user", "order-text-1", "reply-final"),
                     messageIds(snapshot(events)));
         }
 

From 39ecc5f9d3b216c22124f7ce09d5fc079a36a64c Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Sat, 12 Sep 2026 12:59:04 +0800
Subject: [PATCH 17/22] =?UTF-8?q?chore(pr):=20=E6=94=B6=E6=95=9B=E5=8F=98?=
 =?UTF-8?q?=E6=9B=B4=E8=8C=83=E5=9B=B4=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=8E=A7?=
 =?UTF-8?q?=E5=88=B6=E9=9D=A2=E6=94=B9=E5=8A=A8=E4=B8=8E=E5=86=85=E9=83=A8?=
 =?UTF-8?q?=E5=AE=9E=E6=96=BD=E6=8A=A5=E5=91=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../task-9-report.md                          | 155 ------------------
 .../docs/managed_agents/guide/07-events.md    |  14 --
 .../web/managed/SessionEventPreviewBus.java   |  15 +-
 3 files changed, 11 insertions(+), 173 deletions(-)
 delete mode 100644 .superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md

diff --git a/.superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md b/.superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md
deleted file mode 100644
index 01f9ad2676..0000000000
--- a/.superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md
+++ /dev/null
@@ -1,155 +0,0 @@
-# Task 9 交付报告:文档、兼容性与全量验证
-
-日期:2026-09-03
-
-分支:`codex/stream-events-text-disposition`
-
-工作树:`D:\ai-code\agentscope-java\.worktrees\stream-events-text-disposition`
-
-## 结论
-
-Task 9 的文档与 Javadoc 已完成,并验证了 Core 显式启用方式、`TERMINAL` 与最终答案的区别、Remote 字符串 payload 兼容语义、AG-UI/Web 启用方式及默认关闭兼容性。
-
-必需 Maven 命令在当前 Windows 环境中会被两个无法创建符号链接的 Core 用例提前阻断;排除这两个环境用例后,Agent Protocol、AG-UI 与 Data Plane 的目标 reactor 均成功。Harness 的原始补充运行还暴露了三个依赖 Unix `sh` 的环境用例;进一步排除该类后,Harness 及下游目标模块均通过。前端测试通过;完整前端 build 仍被 HEAD 既有缺失的两个 `src/features/build/**` 页面阻断,Task 8 的三个目标文件通过独立严格 TypeScript 检查。
-
-## 实现内容
-
-### Core 示例
-
-- 更新 `AgentEventStreamExample`,使用:
-
-  ```java
-  AgentEventStreams.withTextOutputDisposition(agent.streamEvents(input))
-  ```
-
-- 输出 `replyId -> disposition`。
-- 明确说明 `TERMINAL` 只关闭流式文本生命周期,`AgentResultEvent` 仍是权威调用结果。
-- 保留 opt-in 语义,没有修改 `ReActAgent#streamEvents()` 的默认序列。
-
-### Managed Web 文档
-
-- 增加 `event_update` SSE 事件说明。
-- 说明 Managed Web 服务端已内部启用文本处置派生,客户端通过 `event_deltas=agent.message` 订阅。
-- 说明 `INTERMEDIATE`、`TERMINAL != final answer`、权威空结果清除预览、权威结果校准及不落库语义。
-
-### AG-UI 文档
-
-- 新增模块 README,记录 `.textOutputDispositionEnabled(true)` 的显式启用方式。
-- 明确默认值为 `false`,未启用时保持旧 message ID 与事件序列。
-- 记录 `agentscope.text_output.disposition` CUSTOM 事件及标准 `MESSAGES_SNAPSHOT` 校准事件。
-- 明确 `TERMINAL` 不代表最终答案。
-
-### Remote Javadoc
-
-- 修正 `RemoteEventCodec`、`RemoteEventType`、`RemoteStreamDetail` 的过时说明。
-- 明确 `detail=full` 会包含 `TEXT_OUTPUT_DISPOSITION` 与 `AGENT_RESULT` 两种 `AGENT_EVENT` subtype;其余 passthrough subtype 仍仅在 `verbose` 下可见。
-- 明确 payload 仍是 JSON `String`,`eventType` subtype 也仍是字符串;客户端可以忽略不理解的 subtype。
-- 区分两种 JSON 前向兼容机制:`READ_UNKNOWN_ENUM_VALUES_AS_NULL` 处理未知 wire enum,DTO 的
-  `@JsonIgnoreProperties(ignoreUnknown = true)` 只处理未知字段。
-
-## 路径差异
-
-brief 中 Remote 文件路径指向 Agent Protocol 扩展模块,但当前仓库实际实现位于:
-
-`agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/protocol/`
-
-因此本任务修改了 Harness 中的真实生产类。另一个差异是 AG-UI 模块在 HEAD 及历史中都没有 README;本任务按 brief 指定位置新增该文件,而不是覆盖既有文档。
-
-## 验证记录
-
-所有 Maven 成功复跑均临时使用 `C:\Program Files\Java\jdk-17`;未修改仓库或机器的持久配置。默认 `JAVA_HOME` 是 JDK 21,不满足项目 Maven Enforcer 的 JDK 17 要求。
-
-| 命令 | 退出码 | 结果 |
-| --- | ---: | --- |
-| `mvn spotless:check -DskipTests` | 0 | 91 个 reactor 模块 SUCCESS,无需执行 `spotless:apply` |
-| `mvn -pl agentscope-core test -DskipITs -Dtest='!DangerousPathBypassTest'`(默认 JDK 21 首次运行) | 1 | Enforcer 在测试前拒绝 JDK 21;Tests run: 0 |
-| 同一 Core 命令,临时切换 JDK 17 | 0 | Tests run: 2390,Failures: 0,Errors: 0,Skipped: 8 |
-| `mvn -pl agentscope-harness -am test -DskipITs` | 1 | Core:2399,Failures: 0,Errors: 2,Skipped: 8;两个 `DangerousPathBypassTest` symlink 用例因 Windows “客户端没有所需的特权”失败,Harness 被跳过 |
-| `mvn -pl agentscope-harness -am test -DskipITs -Dtest='!DangerousPathBypassTest'` | 1 | Core 通过;Harness:970,Failures: 0,Errors: 3,Skipped: 7;三个 `DockerSandboxCommandTest` 因系统找不到 `sh` 失败 |
-| `mvn -pl agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol -am test -DskipITs` | 1 | Core:2399,Failures: 0,Errors: 2,Skipped: 8;同一 symlink 环境错误,下游被跳过 |
-| 同一 Agent Protocol 命令,增加 `-Dtest='!DangerousPathBypassTest,!DockerSandboxCommandTest'` | 0 | Core:2390/0/0/8;Harness:964/0/0/6;Agent Protocol:32/0/0/0;reactor 全部 SUCCESS |
-| `mvn -pl agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui -am test -DskipITs` | 1 | Core:2399,Failures: 0,Errors: 2,Skipped: 8;同一 symlink 环境错误,AG-UI 被跳过 |
-| 同一 AG-UI 命令,增加 `-Dtest='!DangerousPathBypassTest,!DockerSandboxCommandTest'` | 0 | Core:2390/0/0/8;AG-UI:528/0/0/0;reactor 全部 SUCCESS |
-| `mvn -pl agentscope-service/service-dataplane -am test -DskipITs` | 1 | Core:2399,Failures: 0,Errors: 2,Skipped: 8;同一 symlink 环境错误,Data Plane 被跳过 |
-| 同一 Data Plane 命令,增加 `-Dtest='!DangerousPathBypassTest,!DockerSandboxCommandTest'` | 0 | 15 个 reactor 模块 SUCCESS;Core:2390/0/0/8;Harness:964/0/0/6;Service Common:21/0/0/0;Data Plane:32/0/0/0 |
-| `npm test -- --run` | 0 | 1 个测试文件通过,6/6 tests passed;npm 对多余 `--run` 给出未来版本配置警告,实际脚本为 `vitest run` |
-| `npm run build` | 1 | `tsc --noEmit` 被 HEAD 既有缺失页面阻断:`DeploymentsPage` 与 `AgentsHubPage` 的 `src/features/build/**` 模块不存在 |
-| 首次直接拼接 TypeScript CLI 参数的目标文件试跑 | 1 | PowerShell/TypeScript CLI 对 `--lib`、`--paths` 参数解析失败;属于验证命令写法问题,不是源文件诊断 |
-| 临时 `tsconfig.task9.json` + `npx tsc --noEmit -p tsconfig.task9.json` | 0 | Task 8 目标文件 `ChatPanel.tsx`、`ChatPanel.test.tsx`、`MessageBlock.tsx` 严格类型检查通过;临时配置随后删除 |
-| `mvn -pl agentscope-examples/documentation -am -DskipTests compile` | 0 | 27 个 reactor 模块 SUCCESS,Documentation 模块 50 个源文件编译成功 |
-| `git diff --check`(自审前) | 0 | 无空白错误 |
-
-### 环境失败明细
-
-Windows 符号链接权限:
-
-- `DangerousPathBypassTest.symlinkToDotEnvIsDetected`
-- `DangerousPathBypassTest.symlinkToSshIsDetected`
-
-缺少 Unix `sh`:
-
-- `DockerSandboxCommandTest.execProcessDestroysTheHostProcessOnTimeout`
-- `DockerSandboxCommandTest.execProcessDestroysTheHostProcessWhenWritingFails`
-- `DockerSandboxCommandTest.execProcessDestroysTheHostProcessWhenInterrupted`
-
-这些失败都发生在环境依赖处,没有观察到本任务修改引发的断言失败。
-
-### 前端基线缺失明细
-
-`npm run build` 的精确 TypeScript 错误:
-
-- `src/main.tsx(54,29): TS2307`:缺少 `./features/build/deployments/DeploymentsPage`
-- `src/pages/AgentsHubPage.tsx(17,25): TS2307`:缺少 `../features/build/agents/AgentsHubPage`
-
-按 brief 要求未创建这些无关页面。
-
-## 默认兼容性复核
-
-- Core:`ReActAgentNewLoopReplyTest.unwrappedTextOnlyStreamPreservesLegacySequenceWithoutDisposition`
-  直接调用未包装的 `ReActAgent#streamEvents()`,断言精确的 8 事件序列
-  `AGENT_START → MODEL_CALL_START → TEXT_BLOCK_START → TEXT_BLOCK_DELTA → TEXT_BLOCK_END → MODEL_CALL_END → AGENT_RESULT → AGENT_END`,
-  并断言不存在 `TextOutputDispositionEvent`。
-- AG-UI:`AguiAdapterConfigTest.testDefaultConfig` 与 `testBuilderWithDefaults` 断言默认关闭;`AguiAgentAdapterV2Test.testTextOutputDispositionRemainsDisabledWithoutChangingLegacySequenceOrMessageId` 覆盖未启用时旧序列和 message ID,AG-UI 全模块 528 个测试通过。
-- Remote:`RemoteAgentEvent.payload` 类型仍为 `String`;`AgentProtocolTaskClient` 的 JSON mapper 通过
-  `READ_UNKNOWN_ENUM_VALUES_AS_NULL` 将未知 `RemoteEventType` 读为 `null`,而 `RemoteAgentEvent` 的
-  `@JsonIgnoreProperties(ignoreUnknown = true)` 独立忽略未知字段。新增
-  `AgentProtocolTaskClientTest.unknownWireEnumAndFieldDoNotDropStringPayload` 通过真实 SSE JSON 路径同时验证这两点及字符串 payload 保留。
-- Final answer filter:`FinalAnswerFilterMiddlewareTest` 的 `finalRoundEmitsBufferedTextBeforeModelCallEnd`、`intermediateRoundSuppressesTextWhenToolCallIsObserved`、`nonTextEventsAreForwarded`、`stateIsolatedAcrossSubscriptions` 均在 Core 回归中通过。
-
-## 自审
-
-- brief Step 1:示例与协议/服务文档已覆盖要求。
-- brief Step 2:Spotless 全 reactor 通过。
-- brief Step 3:五条必需 Maven 命令均已原样运行;环境阻断均精确记录,并用补充命令验证下游目标模块。
-- brief Step 4:前端测试通过;build 基线缺失精确记录;目标文件 TypeScript 检查通过。
-- brief Step 5:四组默认兼容性均有测试或代码证据。
-- brief Step 6:将使用指定提交信息 `docs(streaming): 说明文本处置与结果校准用法` 提交,仅保留本任务文件与本报告。
-
-未发现需要新增生产代码、修改默认行为或扩大文档范围的问题。
-
-## 修复轮 1(2026-09-03)
-
-根据复审 findings 做了以下校正:
-
-- `AgentEventStreamExample` 不再声称当前只打印 disposition 的 callback 会显示所有生命周期/工具事件;
-  无工具序列补入真实 `AGENT_RESULT`,并明确 opt-in wrapper 在 `AgentEndEvent` 前派生
-  `TEXT_OUTPUT_DISPOSITION(TERMINAL)`,顺序为 `AGENT_RESULT → TEXT_OUTPUT_DISPOSITION → AGENT_END`。
-- `07-events.md` 明确 `authoritative=true, hasOutput=false` 的 `event_update` 只用于权威结果无输出时清空预览;
-  普通非空结果不另发 authoritative update,而是由复用同一 ID 的持久化 `agent.message` 校准。
-- `SubagentDeclaration#getRemoteStreamDetail()` 及 builder 的公开 Javadoc 明确 FULL 还包括
-  `TEXT_OUTPUT_DISPOSITION` 与 `AGENT_RESULT`。
-- 用真实 `ReActAgent#streamEvents()` characterization 测试替换不相关的旧数量证据;该测试在首次有效运行即通过,
-  说明现有默认行为已经满足要求,因此没有伪造 RED 或修改生产逻辑。
-- 新增真实 SSE JSON characterization 测试,准确区分未知 enum 与未知字段的处理机制。首次命令因 PowerShell
-  未给带点 Maven 属性加引号而未进入构建;第二次在测试前由 Spotless 报告两处格式差异;按建议修正后首次有效行为运行通过。
-
-本轮新增验证:
-
-| 命令 | 退出码 | 结果 |
-| --- | ---: | --- |
-| `mvn -pl agentscope-core test -DskipITs "-Dtest=ReActAgentNewLoopReplyTest#unwrappedTextOnlyStreamPreservesLegacySequenceWithoutDisposition,AgentEventStreamsTest#emitsResultTerminalThenEndOnNormalCompletion"` | 0 | 2 tests,0 failures/errors;同时覆盖未包装默认序列和 wrapper 的 `AGENT_RESULT → TERMINAL → AGENT_END` 顺序 |
-| `mvn -pl agentscope-harness -am test -DskipITs -Dtest=AgentProtocolTaskClientTest "-Dsurefire.failIfNoSpecifiedTests=false"` | 0 | Harness 目标测试 1/1 通过,3 模块 reactor SUCCESS |
-| `mvn spotless:check -DskipTests` | 0 | 91 个 reactor 模块 SUCCESS |
-| `mvn -pl agentscope-examples/documentation -am -DskipTests compile` | 0 | 27 个 reactor 模块 SUCCESS,Documentation 50 个源文件编译成功 |
-
-本轮没有修改生产行为;只收紧兼容性测试、修正文档/Javadoc,并新增 Remote JSON 边界测试。
diff --git a/agentscope-service/docs/managed_agents/guide/07-events.md b/agentscope-service/docs/managed_agents/guide/07-events.md
index 8b3173b36b..7def6b22c4 100644
--- a/agentscope-service/docs/managed_agents/guide/07-events.md
+++ b/agentscope-service/docs/managed_agents/guide/07-events.md
@@ -26,24 +26,10 @@ curl -N "$BASE/api/sessions/$SESSION_ID/events/stream?event_deltas=agent.message
 |---|---|
 | `event_start` | 即将产生某持久化类型;payload 含 `event_id`、`type` |
 | `event_delta` | 增量文本;payload 含 `event_id`、`type`、`delta` |
-| `event_update` | 更新同一预览的文本处置;权威结果无输出时也用于清空预览;payload 含 `event_id`、`type` 及状态字段 |
 
 完整 `agent.message` / `agent.thinking` 仍会在落库后推送。  
 `GET …/events` **永远看不到** delta。多副本下 deltas 仅 turn-owner best-effort。
 
-Managed Web 的 turn runner 已在服务端启用文本处置派生;客户端无需增加服务端配置,只需像上例一样订阅
-`event_deltas=agent.message`,即可收到同一 `event_id` 的 `event_update`:
-
-- `disposition=INTERMEDIATE`:当前预览只是过程文本,UI 可降级为 commentary。
-- `disposition=TERMINAL`:当前预览的文本生命周期结束;这**不等于最终答案**。
-- `authoritative=true, hasOutput=false`:仅在权威 `AgentResultEvent` 没有输出时发送,用于清除此前预览。
-- 普通非空权威结果**不会**另发 `authoritative=true` 的 `event_update`;最终持久化的 `agent.message` 会复用
-  该 `event_id` 并携带权威内容,以此校准或替换预览。
-
-`event_update` 与 delta 一样只存在于 SSE 流中,不会落库。最终答案应以权威
-`AgentResultEvent` 映射出的 `agent.message`(或 `authoritative=true, hasOutput=false` 的空结果更新)为准,而不是仅凭
-`TERMINAL` 判定。
-
 ## 投递入站
 
 ```bash
diff --git a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java
index a0aad8f550..a6afd4ccff 100644
--- a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java
+++ b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java
@@ -18,6 +18,7 @@
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import org.springframework.stereotype.Component;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Sinks;
@@ -29,7 +30,8 @@
 @Component
 public class SessionEventPreviewBus {
 
-    private final Sinks.Many sink = Sinks.many().multicast().directBestEffort();
+    private final ConcurrentHashMap> sinks =
+            new ConcurrentHashMap<>();
 
     /** Emits an {@code event_start} frame for a forthcoming persisted type. */
     public void emitStart(String sessionId, String targetType, String eventId) {
@@ -62,13 +64,18 @@ public void emitFrame(
     }
 
     public Flux subscribe(String sessionId) {
-        return sink.asFlux().filter(dto -> sessionId.equals(dto.sessionId()));
+        return sinkFor(sessionId).asFlux();
     }
 
-    private synchronized void emit(String sessionId, String type, Map payload) {
+    private void emit(String sessionId, String type, Map payload) {
         SessionEventDto dto =
                 new SessionEventDto(
                         null, sessionId, -1L, type, payload, null, System.currentTimeMillis());
-        sink.tryEmitNext(dto);
+        sinkFor(sessionId).tryEmitNext(dto);
+    }
+
+    private Sinks.Many sinkFor(String sessionId) {
+        return sinks.computeIfAbsent(
+                sessionId, ignored -> Sinks.many().multicast().onBackpressureBuffer(512, false));
     }
 }

From ffa69712f525383ded227e932870636da0b12d01 Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Sat, 12 Sep 2026 13:44:15 +0800
Subject: [PATCH 18/22] =?UTF-8?q?fix(core):=20=E4=BF=AE=E5=A4=8D=E6=96=87?=
 =?UTF-8?q?=E6=9C=AC=E5=A4=84=E7=BD=AE=E6=B5=81=E7=9A=84=E7=BB=93=E6=9D=9F?=
 =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E4=B8=8E=E8=BF=9F=E5=88=B0=E4=BA=8B=E4=BB=B6?=
 =?UTF-8?q?=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

维护者评审提出的两个 critical:

1. 迟到事件原先直接抛 IllegalStateException,经 concatMap 会转成 onError,
   把一个可选的附加信号升级成用户可见的流失败。现改为记录告警后原样透传;
   同一来源再次出现 MODEL_CALL_START 时视为新一次调用并恢复标注。
2. 顶层 AgentEndEvent 原先缓存到流结束后的 complete() 才发出,源出错时 end
   事件丢失,流不终止时永不发出。现改为内联发出 [terminal, end],并删除
   pendingTopLevelEnds 与 complete()。

同时处理评审中的其余意见:

- 子代理结束必须显式声明 OUTCOME_SUCCESS 才产生 TERMINAL,未知或异常 outcome
  不再 fail-open。
- 每个来源在 AgentEndEvent 时清理 tracker 状态,并把结果关联移出 tracker,
  避免 states 无界增长并 pin 住 AgentResultEvent。
- markDispositionEmitted 不再重建已清理的来源状态。
- 补充 AgentEndEvent 的 outcome 契约与新增事件的线格式兼容说明。

新增回归测试:结束先于源错误/源完成发出、迟到事件透传、子代理异常结束不产生
TERMINAL、tracker 状态清理、中间轮文本不泄漏进最终答案。
---
 .../agentscope/core/event/AgentEndEvent.java  |  11 +-
 .../io/agentscope/core/event/AgentEvent.java  |   5 +
 .../core/event/AgentEventStreams.java         | 113 ++++++++------
 .../event/TextOutputDispositionEvent.java     |   7 +
 .../stream/ReplyLifecycleTracker.java         |  32 ++--
 .../core/event/AgentEventStreamsTest.java     | 141 +++++++++++++++---
 .../stream/ReplyLifecycleTrackerTest.java     |  24 +++
 .../FinalAnswerFilterMiddlewareTest.java      |  30 ++++
 8 files changed, 286 insertions(+), 77 deletions(-)

diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java
index 9ae68da3ba..7b3f6a9ebd 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEndEvent.java
@@ -23,7 +23,16 @@
  */
 public class AgentEndEvent extends AgentEvent {
 
-    /** Metadata key describing whether a synthesized invocation end succeeded, failed, or cancelled. */
+    /**
+     * Metadata key describing whether a synthesized invocation end succeeded, failed, or cancelled.
+     *
+     * 

It is written by the producer that synthesizes an end on behalf of a forwarded invocation — + * for example the harness spawn tool, which tags every subagent end with {@link #OUTCOME_SUCCESS}, + * {@link #OUTCOME_ERROR} or {@link #OUTCOME_CANCELLED}. {@link + * AgentEventStreams#withTextOutputDisposition} only treats a subagent end as a normal completion + * when this key is present and set to {@link #OUTCOME_SUCCESS}, so a producer that synthesizes + * subagent ends should set it to keep those replies classifiable. + */ public static final String METADATA_INVOCATION_OUTCOME = "invocationOutcome"; public static final String OUTCOME_SUCCESS = "success"; diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java index c8da5c9534..7efd313e4f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java @@ -29,6 +29,10 @@ * *

Each event carries a unique ID, creation timestamp, and type discriminator. * Events are emitted during agent execution and can be consumed via reactive streams. + * + *

The {@code type} discriminator resolves through the types registered below, and that set grows + * as the library evolves. A consumer can only deserialize the type ids its own revision knows about, + * so it must not assume it can read back everything a newer producer is able to emit. */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) @@ -37,6 +41,7 @@ @JsonSubTypes.Type(value = AgentStartEvent.class, name = "AGENT_START"), @JsonSubTypes.Type(value = AgentEndEvent.class, name = "AGENT_END"), @JsonSubTypes.Type(value = AgentResultEvent.class, name = "AGENT_RESULT"), + // Derived event added after the initial event set: older readers cannot resolve this id. @JsonSubTypes.Type(value = TextOutputDispositionEvent.class, name = "TEXT_OUTPUT_DISPOSITION"), @JsonSubTypes.Type(value = ModelCallStartEvent.class, name = "MODEL_CALL_START"), @JsonSubTypes.Type(value = ModelCallEndEvent.class, name = "MODEL_CALL_END"), diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java index f148b684e3..41ed040e56 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java @@ -27,6 +27,8 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; /** Utilities for deriving optional lifecycle signals from an {@link AgentEvent} stream. */ @@ -41,6 +43,16 @@ private AgentEventStreams() {} * invocation result remains {@link AgentResultEvent}; a terminal disposition only closes the * last visible reply before a normally completed {@link AgentEndEvent}. * + *

The wrapper is purely additive and never fails the source stream. An {@link + * AgentEndEvent} is forwarded as soon as it is observed, preceded by the derived terminal + * disposition when one applies, and any event that arrives after the end of a source is + * forwarded unchanged rather than rejected. + * + *

A top-level terminal requires an authoritative {@link AgentResultEvent} for the same + * source. A subagent terminal additionally requires a synthesized {@link AgentEndEvent} that + * explicitly reports {@link AgentEndEvent#OUTCOME_SUCCESS}: an unknown or abnormal outcome never + * closes the reply. + * * @param source source event stream * @return a deferred stream containing the original events and derived disposition events */ @@ -49,23 +61,34 @@ public static Flux withTextOutputDisposition(Flux source return Flux.defer(() -> new DispositionAnnotator().apply(source)); } + private static final Logger log = LoggerFactory.getLogger(AgentEventStreams.class); + private static final class DispositionAnnotator { private final ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); - private final Map pendingTopLevelEnds = new LinkedHashMap<>(); + private final Map authoritativeResults = new LinkedHashMap<>(); private final Set endedSources = new HashSet<>(); private Flux apply(Flux source) { - Flux processed = - source.concatMap(event -> Flux.fromIterable(process(event)), 1); - return processed.concatWith(Flux.defer(() -> Flux.fromIterable(complete()))); + return source.concatMap(event -> Flux.fromIterable(process(event)), 1); } private List process(AgentEvent event) { SourceKey sourceKey = tracker.sourceKey(event); if (endedSources.contains(sourceKey)) { - throw new IllegalStateException( - "Received event after AgentEndEvent for source " + sourceKey); + if (event instanceof ModelCallStartEvent) { + // A new model call on a closed source starts a fresh invocation. + endedSources.remove(sourceKey); + } else { + // A disposition is an opt-in, additive signal, so an ordering anomaly must + // never fail the wrapped stream: forward the event without classifying it. + log.warn( + "Received {} after AgentEndEvent for source {}; forwarding it without " + + "a disposition classification", + event.getType(), + sourceKey); + return List.of(event); + } } Observation observation = tracker.observe(event); @@ -73,11 +96,17 @@ private List process(AgentEvent event) { case MODEL_CALL_START -> onModelCallStart(event, observation); case TOOL_CALL_START -> onToolCallStart(event, observation); case TEXT_BLOCK_END -> onTextBlockEnd(event, observation); + case AGENT_RESULT -> onAgentResult((AgentResultEvent) event, observation); case AGENT_END -> onAgentEnd((AgentEndEvent) event, observation); default -> List.of(event); }; } + private List onAgentResult(AgentResultEvent event, Observation observation) { + authoritativeResults.put(observation.sourceKey(), event); + return List.of(event); + } + private List onModelCallStart(AgentEvent event, Observation observation) { ReplySnapshot previous = observation.before(); if (hasUnclassifiedText(previous)) { @@ -124,56 +153,54 @@ && hasUnclassifiedText(current)) { private List onAgentEnd(AgentEndEvent event, Observation observation) { SourceKey sourceKey = observation.sourceKey(); endedSources.add(sourceKey); - if (sourceKey.isTopLevel()) { - pendingTopLevelEnds.put(sourceKey, event); - return List.of(); - } ReplySnapshot current = observation.after(); + AgentResultEvent result = authoritativeResults.remove(sourceKey); List output = new ArrayList<>(2); - if (isNormallyCompleted(event) && hasUnclassifiedText(current)) { - output.add( - disposition( - current.replyId(), TextOutputDisposition.TERMINAL, null, event)); - tracker.markDispositionEmitted(sourceKey); - } - output.add(event); - return output; - } - - private List complete() { - List output = new ArrayList<>(pendingTopLevelEnds.size() * 2); - for (Map.Entry entry : pendingTopLevelEnds.entrySet()) { - SourceKey sourceKey = entry.getKey(); - AgentEndEvent end = entry.getValue(); - ReplySnapshot current = tracker.snapshot(sourceKey); - AgentResultEvent result = current.lastResult(); - if (isNormallyCompleted(end) - && hasUnclassifiedText(current) - && result != null - && result.getResult() != null) { - GenerateReason reason = result.getResult().getGenerateReason(); + if (hasUnclassifiedText(current)) { + if (sourceKey.isTopLevel()) { + // ReActAgent emits the authoritative result immediately before the end event, + // so the top-level terminal disposition can be derived inline as well. + if (!isAbnormalEnd(event) && result != null && result.getResult() != null) { + output.add( + disposition( + current.replyId(), + TextOutputDisposition.TERMINAL, + result.getResult().getGenerateReason(), + event)); + tracker.markDispositionEmitted(sourceKey); + } + } else if (isSuccessfulChildEnd(event)) { + // A synthesized child end only closes the reply when it explicitly reports + // success, so an unknown or abnormal outcome never yields a terminal. output.add( disposition( current.replyId(), TextOutputDisposition.TERMINAL, - reason, - end)); + null, + event)); tracker.markDispositionEmitted(sourceKey); } - output.add(end); - tracker.clearSource(sourceKey); } - pendingTopLevelEnds.clear(); + output.add(event); + tracker.clearSource(sourceKey); return output; } - private static boolean isNormallyCompleted(AgentEndEvent end) { - Object outcome = - end.getMetadata() == null - ? null - : end.getMetadata().get(AgentEndEvent.METADATA_INVOCATION_OUTCOME); - return outcome == null || AgentEndEvent.OUTCOME_SUCCESS.equals(outcome.toString()); + private static Object invocationOutcome(AgentEndEvent end) { + return end.getMetadata() == null + ? null + : end.getMetadata().get(AgentEndEvent.METADATA_INVOCATION_OUTCOME); + } + + private static boolean isSuccessfulChildEnd(AgentEndEvent end) { + Object outcome = invocationOutcome(end); + return outcome != null && AgentEndEvent.OUTCOME_SUCCESS.equals(outcome.toString()); + } + + private static boolean isAbnormalEnd(AgentEndEvent end) { + Object outcome = invocationOutcome(end); + return outcome != null && !AgentEndEvent.OUTCOME_SUCCESS.equals(outcome.toString()); } private static boolean hasUnclassifiedText(ReplySnapshot snapshot) { diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java index db2cab5812..1559419e93 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/TextOutputDispositionEvent.java @@ -25,6 +25,13 @@ * *

A terminal disposition is a lifecycle signal, not an authoritative final answer. Consumers * must use {@link AgentResultEvent} for the invocation result. + * + *

Serialization compatibility. This type was added after the initial event set, + * so it is registered additively in {@link AgentEvent}'s type discriminator. A consumer built against + * an older revision cannot resolve the {@code TEXT_OUTPUT_DISPOSITION} type id and will fail to + * deserialize it. Producers therefore must not persist or replay these derived events into an event + * log that older consumers read back: drop them before persistence, or require the reader to be at + * least as new as the writer. */ public final class TextOutputDispositionEvent extends AgentEvent { diff --git a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java index 462f4630a3..4eedacf9be 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java +++ b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java @@ -31,8 +31,13 @@ /** * Internal state tracker shared by stream annotators and middleware that reason about model replies. * - *

This type is public only so internal components in different packages can share one set of - * reply/source correlation rules. It is not a stable public API. + *

This type tracks only reply lifecycle: which reply is current for a source, whether visible text + * or a tool call has been seen for it, and whether a disposition has already been emitted. Correlating + * the authoritative {@link AgentResultEvent} with a source is the caller's responsibility. + * + *

It is public only so internal components in different packages (for example {@code + * io.agentscope.core.middleware}) can share one set of reply/source correlation rules. It is not part + * of the supported API surface and may change without notice; do not use it outside this project. */ public final class ReplyLifecycleTracker { @@ -65,11 +70,7 @@ public boolean isTopLevel() { } public record ReplySnapshot( - String replyId, - boolean textSeen, - boolean toolCallSeen, - boolean dispositionEmitted, - AgentResultEvent lastResult) {} + String replyId, boolean textSeen, boolean toolCallSeen, boolean dispositionEmitted) {} public record Observation( SourceKey sourceKey, @@ -121,7 +122,6 @@ public Observation observe(AgentEvent event) { state.toolCallSeen = true; } } - case AGENT_RESULT -> state.lastResult = (AgentResultEvent) event; default -> { // The remaining event kinds do not mutate shared reply state. } @@ -137,7 +137,15 @@ public ReplySnapshot snapshot(SourceKey sourceKey) { } public void markDispositionEmitted(SourceKey sourceKey) { - states.computeIfAbsent(sourceKey, ignored -> new ReplyState()).dispositionEmitted = true; + ReplyState state = states.get(sourceKey); + if (state != null) { + state.dispositionEmitted = true; + } + } + + /** Number of sources currently holding reply state. Package private for regression tests. */ + int trackedSourceCount() { + return states.size(); } public void clearReply(SourceKey sourceKey) { @@ -216,15 +224,13 @@ private static final class ReplyState { private boolean textSeen; private boolean toolCallSeen; private boolean dispositionEmitted; - private AgentResultEvent lastResult; private ReplySnapshot snapshot() { - return new ReplySnapshot( - replyId, textSeen, toolCallSeen, dispositionEmitted, lastResult); + return new ReplySnapshot(replyId, textSeen, toolCallSeen, dispositionEmitted); } private static ReplySnapshot emptySnapshot() { - return new ReplySnapshot(null, false, false, false, null); + return new ReplySnapshot(null, false, false, false); } } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java index 47927ac134..6323dd1499 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java @@ -262,7 +262,7 @@ void emitsTopLevelEndWithoutTerminalWhenAuthoritativeResultIsNull() { } @Test - void doesNotLeakPendingTopLevelEndOrTerminalOnError() { + void emitsTopLevelTerminalAndEndBeforeSourceErrorPropagates() { RuntimeException failure = new RuntimeException("boom"); AgentEndEvent end = new AgentEndEvent("reply-1"); @@ -278,6 +278,8 @@ void doesNotLeakPendingTopLevelEndOrTerminalOnError() { StepVerifier.create(annotated) .expectNextCount(3) + .expectNextMatches(TextOutputDispositionEvent.class::isInstance) + .expectNext(end) .expectErrorMatches(error -> error == failure) .verify(); } @@ -303,12 +305,10 @@ void cancellationAfterResultDoesNotSynthesizeTerminalDisposition() { } @Test - void cancellationAfterTopLevelEndIsStagedDoesNotLeakTerminalOrEnd() { + void emitsTopLevelTerminalAndEndBeforeSourceCompletes() { TestPublisher source = TestPublisher.create(); AgentResultEvent result = result(GenerateReason.MODEL_STOP); AgentEndEvent end = new AgentEndEvent("reply-1"); - AgentEvent barrier = - tagged(new ModelCallStartEvent("barrier-reply"), "barrier-source", "barrier-task"); StepVerifier.create(AgentEventStreams.withTextOutputDisposition(source.flux()), 0) .thenRequest(1) @@ -322,8 +322,17 @@ void cancellationAfterTopLevelEndIsStagedDoesNotLeakTerminalOrEnd() { .expectNext(result) .thenRequest(1) .then(() -> source.next(end)) - .then(() -> source.next(barrier)) - .expectNext(barrier) + .expectNextMatches( + event -> { + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, event); + assertEquals( + TextOutputDisposition.TERMINAL, disposition.getDisposition()); + assertEquals("reply-1", disposition.getReplyId()); + return true; + }) + .thenRequest(1) + .expectNext(end) .thenCancel() .verify(); @@ -331,22 +340,51 @@ void cancellationAfterTopLevelEndIsStagedDoesNotLeakTerminalOrEnd() { } @Test - void rejectsEventsAfterTopLevelEndWithoutLeakingHeldEvents() { + void forwardsLateEventsWithoutFailingOrClassifyingThem() { + AgentEndEvent end = new AgentEndEvent("reply-1"); AgentResultEvent lateResult = result(GenerateReason.MODEL_STOP); + ToolCallStartEvent lateTool = new ToolCallStartEvent("reply-1", "call-1", "search"); - StepVerifier.create( - AgentEventStreams.withTextOutputDisposition( + List events = + AgentEventStreams.withTextOutputDisposition( Flux.just( new ModelCallStartEvent("reply-1"), new TextBlockDeltaEvent("reply-1", "block-1", "answer"), - new AgentEndEvent("reply-1"), - lateResult))) - .expectNextCount(2) - .expectErrorMatches( - error -> - error instanceof IllegalStateException - && error.getMessage().contains("after AgentEndEvent")) - .verify(); + end, + lateResult, + lateTool)) + .collectList() + .block(); + + assertEquals(5, events.size()); + assertSame(end, events.get(2)); + assertSame(lateResult, events.get(3)); + assertSame(lateTool, events.get(4)); + assertEquals( + 0, events.stream().filter(TextOutputDispositionEvent.class::isInstance).count()); + } + + @Test + void reopensSourceWhenANewInvocationStartsAfterEnd() { + AgentEndEvent end = new AgentEndEvent("reply-1"); + ToolCallStartEvent nextTool = new ToolCallStartEvent("reply-2", "call-2", "search"); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + end, + new ModelCallStartEvent("reply-2"), + new TextBlockDeltaEvent("reply-2", "block-2", "more"), + nextTool)) + .collectList() + .block(); + + assertEquals(7, events.size()); + assertSame(end, events.get(2)); + assertDisposition(events.get(5), "reply-2", TextOutputDisposition.INTERMEDIATE); + assertSame(nextTool, events.get(6)); } @Test @@ -386,8 +424,7 @@ void respectsOneAtATimeDownstreamDemandForDerivedEvents() { @Test void childEndImmediatelyClosesVisibleReplyWithTerminalDisposition() { - AgentEndEvent end = - (AgentEndEvent) tagged(new AgentEndEvent("reply-1"), "worker", "task-1"); + AgentEndEvent end = successfulChildEnd("reply-1", "worker", "task-1"); List events = AgentEventStreams.withTextOutputDisposition( @@ -416,10 +453,66 @@ void childEndImmediatelyClosesVisibleReplyWithTerminalDisposition() { } @Test - void cancellationAfterChildTerminalCanPreventFollowingEnd() { + void childEndWithoutExplicitSuccessOutcomeProducesNoTerminal() { AgentEndEvent end = (AgentEndEvent) tagged(new AgentEndEvent("reply-1"), "worker", "task-1"); + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + end)) + .collectList() + .block(); + + assertEquals(3, events.size()); + assertSame(end, events.get(2)); + assertEquals( + 0, events.stream().filter(TextOutputDispositionEvent.class::isInstance).count()); + } + + @Test + void abnormalChildEndProducesNoTerminal() { + AgentEndEvent end = + (AgentEndEvent) + tagged(new AgentEndEvent("reply-1"), "worker", "task-1") + .withMetadataEntry( + AgentEndEvent.METADATA_INVOCATION_OUTCOME, + AgentEndEvent.OUTCOME_CANCELLED); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + end)) + .collectList() + .block(); + + assertEquals(3, events.size()); + assertEquals( + 0, events.stream().filter(TextOutputDispositionEvent.class::isInstance).count()); + } + + @Test + void cancellationAfterChildTerminalCanPreventFollowingEnd() { + AgentEndEvent end = successfulChildEnd("reply-1", "worker", "task-1"); + StepVerifier.create( AgentEventStreams.withTextOutputDisposition( Flux.just( @@ -444,6 +537,14 @@ private static AgentResultEvent result(GenerateReason reason) { AssistantMessage.builder().textContent("answer").generateReason(reason).build()); } + private static AgentEndEvent successfulChildEnd(String replyId, String source, String taskId) { + return (AgentEndEvent) + tagged(new AgentEndEvent(replyId), source, taskId) + .withMetadataEntry( + AgentEndEvent.METADATA_INVOCATION_OUTCOME, + AgentEndEvent.OUTCOME_SUCCESS); + } + private static AgentEvent tagged(AgentEvent event, String source, String taskId) { return event.withSource(source).withMetadataEntry(AgentEvent.METADATA_TASK_ID, taskId); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java index a7f4f809ad..00a4059794 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java @@ -101,4 +101,28 @@ void modelStartExposesPreviousReplyBeforeResettingState() { assertFalse(next.after().textSeen()); assertFalse(next.after().dispositionEmitted()); } + + @Test + void clearSourceRemovesTrackedState() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + ReplyLifecycleTracker.SourceKey topLevel = ReplyLifecycleTracker.SourceKey.topLevel(); + tracker.observe(new ModelCallStartEvent("reply-1")); + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "answer")); + + tracker.clearSource(topLevel); + + assertEquals(0, tracker.trackedSourceCount()); + } + + @Test + void markDispositionEmittedDoesNotRecreateClearedSource() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + ReplyLifecycleTracker.SourceKey topLevel = ReplyLifecycleTracker.SourceKey.topLevel(); + tracker.observe(new ModelCallStartEvent("reply-1")); + tracker.clearSource(topLevel); + + tracker.markDispositionEmitted(topLevel); + + assertEquals(0, tracker.trackedSourceCount()); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/middleware/FinalAnswerFilterMiddlewareTest.java b/agentscope-core/src/test/java/io/agentscope/core/middleware/FinalAnswerFilterMiddlewareTest.java index c8a8cdf02b..cab7b7fe59 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/middleware/FinalAnswerFilterMiddlewareTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/middleware/FinalAnswerFilterMiddlewareTest.java @@ -138,6 +138,36 @@ void stateIsolatedAcrossSubscriptions() { .anyMatch(event -> "final answer".equals(event.getDelta()))); } + @Test + void textFromAnIntermediateRoundIsNotLeakedIntoTheFinalRound() { + List events = + apply( + Flux.just( + new ModelCallStartEvent(REPLY_ID), + new TextBlockDeltaEvent(REPLY_ID, "text", "checking"), + new ToolCallStartEvent(REPLY_ID, "tool-1", "search"), + new ModelCallEndEvent(REPLY_ID, (ChatUsage) null), + new ModelCallStartEvent("reply-2"), + new TextBlockStartEvent("reply-2", "text"), + new TextBlockDeltaEvent("reply-2", "text", "final answer"), + new TextBlockEndEvent("reply-2", "text"), + new ModelCallEndEvent("reply-2", (ChatUsage) null))); + + assertFalse( + textDeltas(events).stream().anyMatch(delta -> "checking".equals(delta.getDelta())), + "text from a round that produced a tool call must not reach the final answer"); + assertTrue( + textDeltas(events).stream() + .anyMatch(delta -> "final answer".equals(delta.getDelta()))); + } + + private static List textDeltas(List events) { + return events.stream() + .filter(TextBlockDeltaEvent.class::isInstance) + .map(TextBlockDeltaEvent.class::cast) + .toList(); + } + private List apply(Flux source) { return middleware .onReasoning( From c9503ab1dfb294e9f3a97030b6d5ec4ec35f8385 Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:28:58 +0800 Subject: [PATCH 19/22] =?UTF-8?q?fix(streaming):=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=A4=84=E7=BD=AE=E4=B8=8E=20AG-UI=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E7=9A=84=E7=AC=AC=E4=BA=8C=E8=BD=AE?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 维护者第二轮复审提出的四项风险: 1. 子来源墓碑无界:endedSources 里的子来源键按次生成(task_),只增不减 等价于让一个订阅保留它见过的所有子调用。现在子来源在结束事件发出后立即回收, 顶层键保留到下一次模型调用重新打开,与 clearSource 的生命周期保持一致。 2. 派生处置事件继承了触发的整体 metadata:从 AgentEndEvent 派生的 TERMINAL 会带上 METADATA_INVOCATION_OUTCOME,消费者据此判断会与权威结果矛盾。现在只复制 taskId 这一关联键。 3. AG-UI 的 replyId 靠消息 id 正则反推:任何以 -text/-thinking/-reasoning 结尾的合法 id 都会被归到别的 replyId,非文本转换器产生的 id 还会落到 messageId==replyId 的 伪桶。现在文本转换器显式传入 replyId,正则只作兼容回退。 4. MESSAGES_SNAPSHOT 会丢掉本轮用户消息:仅当 agent 状态回显该消息时才保留 run 输入,而消费者按快照替换流式文本,状态未回显时该轮次在 UI 上消失。现在 run 输入 始终保留。 同时 getTextMessageIds 不再随 textOutputDispositionEnabled 返回不同键空间, 并删除只写不读的 activeTextMessageIdsByReply 与 currentTextReplyId。 新增回归测试:子来源回收与顶层墓碑释放、处置事件只带关联键、中间轮文本不泄漏、 拒绝工具的 DENIED 结果在 acting 中间件下仍发布、询问工具的停止路径仍发布、 agent 状态不回显用户轮次时快照仍保留该轮次。 --- .../core/event/AgentEventStreams.java | 43 ++++++- .../core/agent/ReActAgentHitlTest.java | 107 ++++++++++++++++++ .../core/event/AgentEventStreamsTest.java | 94 +++++++++++++++ .../adapter/strategy/AguiStreamContext.java | 64 ++++++----- .../strategy/TextBlockEventConverter.java | 4 +- .../agui/adapter/AguiAgentAdapterV2Test.java | 37 +++++- 6 files changed, 310 insertions(+), 39 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java index 41ed040e56..eec45a4af5 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java @@ -46,7 +46,8 @@ private AgentEventStreams() {} *

The wrapper is purely additive and never fails the source stream. An {@link * AgentEndEvent} is forwarded as soon as it is observed, preceded by the derived terminal * disposition when one applies, and any event that arrives after the end of a source is - * forwarded unchanged rather than rejected. + * forwarded unchanged rather than rejected. Per-source bookkeeping is reclaimed when the source + * ends, so a subscription that fans out to many subagents does not retain them. * *

A top-level terminal requires an authoritative {@link AgentResultEvent} for the same * source. A subagent terminal additionally requires a synthesized {@link AgentEndEvent} that @@ -63,16 +64,24 @@ public static Flux withTextOutputDisposition(Flux source private static final Logger log = LoggerFactory.getLogger(AgentEventStreams.class); - private static final class DispositionAnnotator { + static final class DispositionAnnotator { private final ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); private final Map authoritativeResults = new LinkedHashMap<>(); private final Set endedSources = new HashSet<>(); - private Flux apply(Flux source) { + Flux apply(Flux source) { return source.concatMap(event -> Flux.fromIterable(process(event)), 1); } + /** + * Number of sources whose per-subscription bookkeeping is still retained. Package private so + * regression tests can assert that ended sources are reclaimed. + */ + int retainedSourceCount() { + return endedSources.size() + authoritativeResults.size(); + } + private List process(AgentEvent event) { SourceKey sourceKey = tracker.sourceKey(event); if (endedSources.contains(sourceKey)) { @@ -103,7 +112,12 @@ private List process(AgentEvent event) { } private List onAgentResult(AgentResultEvent event, Observation observation) { - authoritativeResults.put(observation.sourceKey(), event); + // Only the top-level terminal consults the authoritative result; a subagent terminal is + // derived from its own end event. Recording child results would retain every subagent + // that never reports an end for the life of the subscription. + if (observation.sourceKey().isTopLevel()) { + authoritativeResults.put(observation.sourceKey(), event); + } return List.of(event); } @@ -152,7 +166,6 @@ && hasUnclassifiedText(current)) { private List onAgentEnd(AgentEndEvent event, Observation observation) { SourceKey sourceKey = observation.sourceKey(); - endedSources.add(sourceKey); ReplySnapshot current = observation.after(); AgentResultEvent result = authoritativeResults.remove(sourceKey); @@ -184,6 +197,16 @@ private List onAgentEnd(AgentEndEvent event, Observation observation } output.add(event); tracker.clearSource(sourceKey); + if (sourceKey.isTopLevel()) { + // The top-level key is reused by the next user turn, so one tombstone is enough: + // the next model call reopens it and trailing events stay gated until then. + endedSources.add(sourceKey); + } else { + // Child keys are minted per invocation, so a tombstone each would retain every + // subagent this subscription has ever seen. The reply state is reclaimed above and + // a reused key simply starts a fresh source. + endedSources.remove(sourceKey); + } return output; } @@ -216,7 +239,15 @@ private static TextOutputDispositionEvent disposition( AgentEvent trigger) { TextOutputDispositionEvent event = new TextOutputDispositionEvent(replyId, disposition, generateReason); - event.withSource(trigger.getSource()).withMetadata(trigger.getMetadata()); + event.withSource(trigger.getSource()); + // Carry the correlation keys only: metadata on the trigger describes the triggering + // invocation (for example its outcome), not this derived classification. + if (trigger.getMetadata() != null) { + Object taskId = trigger.getMetadata().get(AgentEvent.METADATA_TASK_ID); + if (taskId != null) { + event.withMetadataEntry(AgentEvent.METADATA_TASK_ID, taskId); + } + } return event; } } 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 ad708896a4..b8938a60c0 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,7 @@ 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.UserConfirmResultEvent; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.GenerateReason; @@ -36,6 +37,8 @@ import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolResultState; import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.middleware.ActingInput; +import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.model.ChatModelBase; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.GenerateOptions; @@ -49,7 +52,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; import java.util.function.Supplier; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -173,6 +178,34 @@ public Mono callAsync(ToolCallParam param) { } } + private static final class DenyingTool extends ToolBase { + DenyingTool(String name) { + super(name, "auto-deny", schemaFor(), true, true, false, null, false, false); + } + + private static Map schemaFor() { + Map schema = new HashMap<>(); + schema.put("type", "object"); + Map props = new HashMap<>(); + Map q = new HashMap<>(); + q.put("type", "string"); + props.put("query", q); + schema.put("properties", props); + return schema; + } + + @Override + public Mono checkPermissions( + Map toolInput, PermissionContextState context) { + return Mono.just(PermissionDecision.deny("denied by test rule")); + } + + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.just(ToolResultBlock.text("must not run")); + } + } + private static Toolkit toolkitWith(ToolBase... tools) { Toolkit tk = new Toolkit(); for (ToolBase t : tools) { @@ -656,4 +689,78 @@ void allowingToolBypassesHitlEntirely() { (ToolResultEndEvent) events.get(indexOf(events, ToolResultEndEvent.class)); assertEquals(ToolResultState.SUCCESS, end.getState()); } + + @Test + void deniedToolResultEventsStayPublishedThroughActingMiddleware() { + List observedByMiddleware = new CopyOnWriteArrayList<>(); + MiddlewareBase recordingMiddleware = + new MiddlewareBase() { + @Override + public Flux onActing( + Agent agent, + RuntimeContext ctx, + ActingInput input, + Function> next) { + return next.apply(input).doOnNext(observedByMiddleware::add); + } + }; + ChatModelBase model = + new ScriptedModel( + List.of( + () -> Flux.just(toolUseResponse("tc1", "blocked", "x")), + () -> Flux.just(textResponse("done")))); + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(model) + .toolkit(toolkitWith(new DenyingTool("blocked"))) + .middleware(recordingMiddleware) + .build(); + + List events = agent.streamEvents(List.of()).collectList().block(); + + assertNotNull(events); + assertEquals( + 1, + countOf(events, ToolResultStartEvent.class), + "the denied tool result start must reach the published stream exactly once"); + assertEquals(1, countOf(events, ToolResultEndEvent.class)); + ToolResultEndEvent end = + (ToolResultEndEvent) events.get(indexOf(events, ToolResultEndEvent.class)); + assertEquals("tc1", end.getToolCallId()); + assertEquals(ToolResultState.DENIED, end.getState()); + assertTrue( + observedByMiddleware.stream().anyMatch(ToolResultEndEvent.class::isInstance), + "the acting middleware must observe the same denied result the stream publishes"); + } + + @Test + void askingToolStopPathStaysPublishedThroughActingMiddleware() { + MiddlewareBase passThroughMiddleware = + new MiddlewareBase() { + @Override + public Flux onActing( + Agent agent, + RuntimeContext ctx, + ActingInput input, + Function> next) { + return next.apply(input); + } + }; + ChatModelBase model = + new ScriptedModel(List.of(() -> Flux.just(toolUseResponse("tc1", "ask", "x")))); + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(model) + .toolkit(toolkitWith(new AskingTool("ask"))) + .middleware(passThroughMiddleware) + .build(); + + List events = agent.streamEvents(List.of()).collectList().block(); + + assertNotNull(events); + assertEquals(1, countOf(events, RequireUserConfirmEvent.class)); + assertEquals(1, countOf(events, RequestStopEvent.class)); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java index 6323dd1499..67d4851454 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java @@ -16,6 +16,7 @@ package io.agentscope.core.event; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -452,6 +453,99 @@ void childEndImmediatelyClosesVisibleReplyWithTerminalDisposition() { assertSame(end, events.get(3)); } + @Test + void childDispositionCarriesCorrelationKeysOnly() { + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + successfulChildEnd("reply-1", "worker", "task-1"))) + .collectList() + .block(); + + TextOutputDispositionEvent disposition = + assertInstanceOf(TextOutputDispositionEvent.class, events.get(2)); + assertEquals("task-1", disposition.getMetadata().get(AgentEvent.METADATA_TASK_ID)); + assertFalse( + disposition.getMetadata().containsKey(AgentEndEvent.METADATA_INVOCATION_OUTCOME), + "the trigger's invocation outcome describes the invocation, not the disposition"); + } + + @Test + void reclaimsEndedChildSources() { + AgentEventStreams.DispositionAnnotator annotator = + new AgentEventStreams.DispositionAnnotator(); + + List events = + annotator + .apply( + Flux.just( + tagged( + new ModelCallStartEvent("reply-1"), + "worker", + "task-1"), + tagged( + new TextBlockDeltaEvent( + "reply-1", "block-1", "answer"), + "worker", + "task-1"), + successfulChildEnd("reply-1", "worker", "task-1"), + tagged( + new ModelCallStartEvent("reply-2"), + "worker", + "task-2"), + tagged( + new TextBlockDeltaEvent( + "reply-2", "block-2", "answer"), + "worker", + "task-2"), + successfulChildEnd("reply-2", "worker", "task-2"), + // A subagent that reports a result but never an end must + // not + // be retained either. + tagged( + result(GenerateReason.MODEL_STOP), + "worker", + "task-3"))) + .collectList() + .block(); + + assertEquals(9, events.size()); + assertEquals( + 0, + annotator.retainedSourceCount(), + "child sources must not be retained after their end event or result"); + } + + @Test + void releasesTopLevelBookkeepingWhenTheNextInvocationStarts() { + AgentEventStreams.DispositionAnnotator annotator = + new AgentEventStreams.DispositionAnnotator(); + + List events = + annotator + .apply( + Flux.just( + new ModelCallStartEvent("reply-1"), + new TextBlockDeltaEvent("reply-1", "block-1", "answer"), + result(GenerateReason.MODEL_STOP), + new AgentEndEvent("reply-1"), + new ModelCallStartEvent("reply-2"))) + .collectList() + .block(); + + assertEquals(6, events.size()); + assertEquals(0, annotator.retainedSourceCount()); + } + @Test void childEndWithoutExplicitSuccessOutcomeProducesNoTerminal() { AgentEndEvent end = diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java index 9f10477037..8819be43a4 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java @@ -82,10 +82,8 @@ public class AguiStreamContext { private final Set endedToolCalls = new LinkedHashSet<>(); private final Set adoptedToolCalls = new LinkedHashSet<>(); private String currentTextMessageId; - private String currentTextReplyId; private String currentReasoningMessageId; private final Map> textMessageIdsByReply = new LinkedHashMap<>(); - private final Map activeTextMessageIdsByReply = new LinkedHashMap<>(); private final Map textOutputDispositionsByReply = new LinkedHashMap<>(); private final Map toolResultContent = new LinkedHashMap<>(); @@ -186,25 +184,38 @@ TokenUsageAccumulator getTokenUsageAccumulator() { return tokenUsageAccumulator; } + /** + * Starts a live text message without naming the reply it belongs to. Callers that know the reply + * id should use {@link #startTextMessage(String, String)} instead: this overload has to recover + * it from the message id naming convention. + */ public void startTextMessage(String messageId) { - String replyId = replyIdOf(messageId); + startTextMessage(messageId, replyIdOf(messageId)); + } + + /** + * Starts a live text message owned by {@code replyId}. The reply id is recorded explicitly so the + * correlation never depends on how the message id was named. + */ + public void startTextMessage(String messageId, String replyId) { + String owner = replyId != null ? replyId : replyIdOf(messageId); if (startedTextMessages.add(messageId)) { - if (config.isTextOutputDispositionEnabled()) { - textMessageIdsByReply - .computeIfAbsent(replyId, ignored -> new ArrayList<>()) - .add(messageId); - activeTextMessageIdsByReply.put(replyId, messageId); - } + textMessageIdsByReply + .computeIfAbsent(owner, ignored -> new ArrayList<>()) + .add(messageId); emit(new AguiEvent.TextMessageStart(threadId, runId, messageId, "assistant")); - emitRememberedTextOutputDisposition(replyId); + emitRememberedTextOutputDisposition(owner); } - currentTextReplyId = replyId; currentTextMessageId = messageId; } public void appendTextDelta(String messageId, String delta) { + appendTextDelta(messageId, replyIdOf(messageId), delta); + } + + public void appendTextDelta(String messageId, String replyId, String delta) { if (delta != null && !delta.isEmpty()) { - startTextMessage(messageId); + startTextMessage(messageId, replyId); emit(new AguiEvent.TextMessageContent(threadId, runId, currentTextMessageId, delta)); } } @@ -225,18 +236,16 @@ public void closeTextMessage(String messageId) { endedTextMessages.add(messageId); if (Objects.equals(messageId, currentTextMessageId)) { currentTextMessageId = null; - currentTextReplyId = null; - } - if (config.isTextOutputDispositionEnabled()) { - activeTextMessageIdsByReply.remove(replyIdOf(messageId), messageId); } emit(new AguiEvent.TextMessageEnd(threadId, runId, messageId)); } + /** + * Returns the AG-UI text message ids that were started for {@code replyId}, in start order. Ids + * are recorded for every reply regardless of {@code textOutputDispositionEnabled}, so the answer + * does not depend on that flag; the list is empty when nothing was streamed for the reply. + */ public List getTextMessageIds(String replyId) { - if (!config.isTextOutputDispositionEnabled()) { - return startedTextMessages.contains(replyId) ? List.of(replyId) : List.of(); - } return List.copyOf(textMessageIdsByReply.getOrDefault(replyId, List.of())); } @@ -258,9 +267,7 @@ public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) { } Map messagesById = new LinkedHashMap<>(); List authoritativeMessages = authoritativeMessagesSupplier.get(); - boolean hasAuthoritativeMessages = - authoritativeMessages != null && !authoritativeMessages.isEmpty(); - if (hasAuthoritativeMessages) { + if (authoritativeMessages != null) { for (Msg message : authoritativeMessages) { if (message != null && !isGeneratedTextSegmentId(message.getId())) { messagesById.put(message.getId(), messageConverter.toAguiMessage(message)); @@ -268,11 +275,10 @@ public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) { } } if (runInput != null) { + // The submitted turn must survive the snapshot: agent state is not guaranteed to echo + // it, and consumers reconcile by replacing their streamed text with this snapshot. for (AguiMessage message : runInput.getMessages()) { - if (message != null - && !isGeneratedTextSegmentId(message.getId()) - && (!hasAuthoritativeMessages - || messagesById.containsKey(message.getId()))) { + if (message != null && !isGeneratedTextSegmentId(message.getId())) { messagesById.put(message.getId(), message); } } @@ -462,8 +468,10 @@ private StringBuilder toolResultBuffer(String toolCallId) { } /** - * Recovers the owning reply id from a live text-segment message id. Ids that are not segment ids - * (for example a single-block reply id) are their own reply id. + * Compatibility fallback that recovers the owning reply id from a live text-segment message id + * ({@code -text[-N]}, {@code -thinking[-N]}). The converters know the reply id + * they are streaming and pass it explicitly; ids that are not segment ids are treated as their + * own reply id, which is only correct when the producer really used the reply id as a message id. */ private static String replyIdOf(String messageId) { if (messageId == null) { diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockEventConverter.java index 49e1d5c8d9..b66a193353 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockEventConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/TextBlockEventConverter.java @@ -34,7 +34,9 @@ public void convert(AgentEvent event, AguiStreamContext context) { if (event instanceof TextBlockDeltaEvent delta) { // AguiEvent.TextMessageStart delays sending when content arrives context.appendTextDelta( - messageId(delta.getReplyId(), delta.getBlockId()), delta.getDelta()); + messageId(delta.getReplyId(), delta.getBlockId()), + delta.getReplyId(), + delta.getDelta()); } else if (event instanceof TextBlockEndEvent end) { context.closeTextMessage(messageId(end.getReplyId(), end.getBlockId())); } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java index 573df7f604..7d26b831e4 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java @@ -665,9 +665,13 @@ void testFinalSnapshotUsesSessionMessagesAndResultDeduplicatedById() { assertEquals(finishedIndex - 1, snapshotIndex); assertEquals( - List.of("session-user", "reply-final"), + List.of("session-user", "reply-final", "msg-1"), snapshot.messages().stream().map(AguiMessage::getId).toList()); - AguiMessage resultMessage = snapshot.messages().get(1); + AguiMessage resultMessage = + snapshot.messages().stream() + .filter(message -> "reply-final".equals(message.getId())) + .findFirst() + .orElseThrow(); assertEquals("canonical result", resultMessage.getTextContent()); assertEquals(1, resultMessage.getToolCalls().size()); assertFalse( @@ -676,6 +680,31 @@ void testFinalSnapshotUsesSessionMessagesAndResultDeduplicatedById() { .anyMatch("reply-final-text-live"::equals)); } + @Test + void testFinalSnapshotKeepsSubmittedTurnWhenAgentStateDoesNotEchoIt() { + Msg assistantOnly = + AssistantMessage.builder() + .id("reply-previous") + .content(TextBlock.builder().text("previous answer").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + AgentState state = AgentState.builder().context(List.of(assistantOnly)).build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + Msg finalResult = + AssistantMessage.builder() + .id("reply-final") + .content(TextBlock.builder().text("canonical result").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + List events = runTerminalDisposition(callerContext, finalResult); + + assertEquals( + List.of("reply-previous", "msg-1", "reply-final"), + messageIds(snapshot(events)), + "the submitted turn must survive a snapshot that does not echo it"); + } + @Test void testFinalSnapshotFallsBackToOriginalInputWithoutAgentState() { AguiMessage inputMessage = @@ -725,7 +754,7 @@ void testFinalSnapshotExcludesOnlySegmentsCreatedByCurrentRun() { runTerminalDisposition(GenerateReason.MODEL_STOP, callerContext); assertEquals( - List.of("session-user", "order-text-1", "reply-final"), + List.of("session-user", "order-text-1", "msg-1", "reply-final"), messageIds(snapshot(events))); } @@ -790,7 +819,7 @@ void testFinalSnapshotPreservesMultimodalStateAndResultContent() { MessageContent.Blocks resultContent = assertInstanceOf( - MessageContent.Blocks.class, snapshot.messages().get(1).getContent()); + MessageContent.Blocks.class, snapshot.messages().get(2).getContent()); assertEquals( List.of(TextInputContent.class, VideoInputContent.class), resultContent.parts().stream().map(Object::getClass).toList()); From 7dbb1d7c94276286fec6554b7306ff93d4eecbf0 Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:13:53 +0800 Subject: [PATCH 20/22] =?UTF-8?q?fix(streaming):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E7=8A=B6=E6=80=81=E4=BF=9D=E7=95=99=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=20AG-UI=20=E6=9D=83=E5=A8=81=E5=BF=AB?= =?UTF-8?q?=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/event/AgentEventStreams.java | 2 +- .../stream/ReplyLifecycleTracker.java | 43 +++++++++++++---- .../core/event/AgentEventStreamsTest.java | 13 ++++-- .../stream/ReplyLifecycleTrackerTest.java | 25 ++++++++++ .../adapter/strategy/AguiStreamContext.java | 8 +++- .../agui/adapter/AguiAgentAdapterV2Test.java | 46 +++++++++++++++++++ 6 files changed, 122 insertions(+), 15 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java index eec45a4af5..80aa763d0a 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java @@ -79,7 +79,7 @@ Flux apply(Flux source) { * regression tests can assert that ended sources are reclaimed. */ int retainedSourceCount() { - return endedSources.size() + authoritativeResults.size(); + return endedSources.size() + authoritativeResults.size() + tracker.trackedSourceCount(); } private List process(AgentEvent event) { diff --git a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java index 4eedacf9be..49d990712e 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java +++ b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java @@ -41,6 +41,8 @@ */ public final class ReplyLifecycleTracker { + static final int MAX_TRACKED_SOURCES = 4096; + public enum EventKind { MODEL_CALL_START, MODEL_CALL_END, @@ -80,7 +82,13 @@ public record Observation( ReplySnapshot before, ReplySnapshot after) {} - private final Map states = new LinkedHashMap<>(); + private final Map states = + new LinkedHashMap<>() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_TRACKED_SOURCES; + } + }; public SourceKey sourceKey(AgentEvent event) { Objects.requireNonNull(event, "event"); @@ -94,15 +102,21 @@ public SourceKey sourceKey(AgentEvent event) { public Observation observe(AgentEvent event) { Objects.requireNonNull(event, "event"); SourceKey sourceKey = sourceKey(event); - ReplyState state = states.computeIfAbsent(sourceKey, ignored -> new ReplyState()); - ReplySnapshot before = state.snapshot(); + ReplyState state = states.get(sourceKey); + ReplySnapshot before = state != null ? state.snapshot() : ReplyState.emptySnapshot(); EventKind kind = eventKind(event); String eventReplyId = replyId(event); boolean currentReplyEvent = - eventReplyId != null && Objects.equals(state.replyId, eventReplyId); + state != null + && eventReplyId != null + && Objects.equals(state.replyId, eventReplyId); switch (kind) { case MODEL_CALL_START -> { + if (state == null) { + state = new ReplyState(); + states.put(sourceKey, state); + } state.replyId = eventReplyId; state.textSeen = false; state.toolCallSeen = false; @@ -110,7 +124,8 @@ public Observation observe(AgentEvent event) { currentReplyEvent = true; } case TEXT_BLOCK_DELTA -> { - if (currentReplyEvent + if (state != null + && currentReplyEvent && event instanceof TextBlockDeltaEvent delta && delta.getDelta() != null && !delta.getDelta().isEmpty()) { @@ -118,7 +133,7 @@ public Observation observe(AgentEvent event) { } } case TOOL_CALL_START -> { - if (currentReplyEvent) { + if (state != null && currentReplyEvent) { state.toolCallSeen = true; } } @@ -128,7 +143,12 @@ public Observation observe(AgentEvent event) { } return new Observation( - sourceKey, kind, eventReplyId, currentReplyEvent, before, state.snapshot()); + sourceKey, + kind, + eventReplyId, + currentReplyEvent, + before, + state != null ? state.snapshot() : ReplyState.emptySnapshot()); } public ReplySnapshot snapshot(SourceKey sourceKey) { @@ -143,8 +163,13 @@ public void markDispositionEmitted(SourceKey sourceKey) { } } - /** Number of sources currently holding reply state. Package private for regression tests. */ - int trackedSourceCount() { + /** + * Number of sources currently holding reply state. + * + *

Public only so internal stream annotators can account for all retained bookkeeping. Not part + * of the supported API surface. + */ + public int trackedSourceCount() { return states.size(); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java index 67d4851454..b2a58c106e 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java @@ -498,6 +498,10 @@ void reclaimsEndedChildSources() { "worker", "task-1"), successfulChildEnd("reply-1", "worker", "task-1"), + tagged( + new TextBlockEndEvent("reply-1", "block-1"), + "worker", + "task-1"), tagged( new ModelCallStartEvent("reply-2"), "worker", @@ -518,7 +522,7 @@ void reclaimsEndedChildSources() { .collectList() .block(); - assertEquals(9, events.size()); + assertEquals(10, events.size()); assertEquals( 0, annotator.retainedSourceCount(), @@ -526,7 +530,7 @@ void reclaimsEndedChildSources() { } @Test - void releasesTopLevelBookkeepingWhenTheNextInvocationStarts() { + void topLevelBookkeepingDoesNotGrowAcrossInvocations() { AgentEventStreams.DispositionAnnotator annotator = new AgentEventStreams.DispositionAnnotator(); @@ -543,7 +547,10 @@ void releasesTopLevelBookkeepingWhenTheNextInvocationStarts() { .block(); assertEquals(6, events.size()); - assertEquals(0, annotator.retainedSourceCount()); + assertEquals( + 1, + annotator.retainedSourceCount(), + "only the active invocation state should remain after reopening the source"); } @Test diff --git a/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java index 00a4059794..83f0dffe66 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java @@ -125,4 +125,29 @@ void markDispositionEmittedDoesNotRecreateClearedSource() { assertEquals(0, tracker.trackedSourceCount()); } + + @Test + void eventsThatCannotEstablishAConversationDoNotCreateTrackedState() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + ReplyLifecycleTracker.SourceKey topLevel = ReplyLifecycleTracker.SourceKey.topLevel(); + tracker.observe(new ModelCallStartEvent("reply-1")); + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "answer")); + tracker.clearSource(topLevel); + + tracker.observe(new TextBlockDeltaEvent("reply-1", "block-1", "late")); + tracker.observe(new ToolCallStartEvent("reply-1", "tool-1", "search")); + + assertEquals(0, tracker.trackedSourceCount()); + } + + @Test + void capsTrackedSources() { + ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); + + for (int i = 0; i < 4097; i++) { + tracker.observe(new ModelCallStartEvent("reply-" + i).withSource("source-" + i)); + } + + assertEquals(4096, tracker.trackedSourceCount()); + } } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java index 8819be43a4..69a7a668ac 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java @@ -267,6 +267,8 @@ public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) { } Map messagesById = new LinkedHashMap<>(); List authoritativeMessages = authoritativeMessagesSupplier.get(); + boolean hasAuthoritativeMessages = + authoritativeMessages != null && !authoritativeMessages.isEmpty(); if (authoritativeMessages != null) { for (Msg message : authoritativeMessages) { if (message != null && !isGeneratedTextSegmentId(message.getId())) { @@ -278,8 +280,10 @@ public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) { // The submitted turn must survive the snapshot: agent state is not guaranteed to echo // it, and consumers reconcile by replacing their streamed text with this snapshot. for (AguiMessage message : runInput.getMessages()) { - if (message != null && !isGeneratedTextSegmentId(message.getId())) { - messagesById.put(message.getId(), message); + if (message != null + && !isGeneratedTextSegmentId(message.getId()) + && (!hasAuthoritativeMessages || "user".equals(message.getRole()))) { + messagesById.putIfAbsent(message.getId(), message); } } } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java index 7d26b831e4..33568d698e 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java @@ -705,6 +705,52 @@ void testFinalSnapshotKeepsSubmittedTurnWhenAgentStateDoesNotEchoIt() { "the submitted turn must survive a snapshot that does not echo it"); } + @Test + void testFinalSnapshotDoesNotLetRunInputOverrideAuthoritativeHistory() { + Msg authoritativeUser = + Msg.builder() + .id("shared-user") + .role(MsgRole.USER) + .textContent("state user") + .build(); + Msg authoritativeAssistant = + AssistantMessage.builder() + .id("shared-assistant") + .content(TextBlock.builder().text("state answer").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + AgentState state = + AgentState.builder() + .context(List.of(authoritativeUser, authoritativeAssistant)) + .build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + RunAgentInput runInput = + inputBuilder() + .messages( + List.of( + AguiMessage.userMessage("shared-user", "client user"), + AguiMessage.assistantMessage( + "shared-assistant", "client answer"), + AguiMessage.assistantMessage( + "client-only", "injected assistant"))) + .build(); + Msg result = + AssistantMessage.builder() + .id("reply-final") + .content(TextBlock.builder().text("canonical result").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + + AguiEvent.MessagesSnapshot snapshot = + snapshot(runTerminalDisposition(runInput, callerContext, result)); + + assertEquals( + List.of("shared-user", "shared-assistant", "reply-final"), + messageIds(snapshot)); + assertEquals("state user", snapshot.messages().get(0).getTextContent()); + assertEquals("state answer", snapshot.messages().get(1).getTextContent()); + } + @Test void testFinalSnapshotFallsBackToOriginalInputWithoutAgentState() { AguiMessage inputMessage = From 071745681b6bdb747b6c3b83b8f32984f6eba394 Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:55:15 +0800 Subject: [PATCH 21/22] =?UTF-8?q?fix(streaming):=20=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E9=A1=B6=E5=B1=82=E5=9B=9E=E5=A4=8D=E7=8A=B6=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=A7=92=E8=89=B2=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将顶层回复状态独立于有界子来源缓存,避免大规模子代理扇出淘汰进行中的顶层回复\n- AG-UI 快照复用大小写不敏感的用户角色判断\n- 增加顶层终态保留和混合大小写用户回合回归测试 --- .../stream/ReplyLifecycleTracker.java | 41 +++++++++++++++---- .../core/event/AgentEventStreamsTest.java | 40 ++++++++++++++++++ .../adapter/strategy/AguiStreamContext.java | 2 +- .../agui/adapter/AguiAgentAdapterV2Test.java | 15 ++++++- 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java index 49d990712e..835ba6fb51 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java +++ b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java @@ -82,7 +82,10 @@ public record Observation( ReplySnapshot before, ReplySnapshot after) {} - private final Map states = + // Keep the active top-level reply outside the child cap so fan-out cannot evict it. + private ReplyState topLevelState; + + private final Map childStates = new LinkedHashMap<>() { @Override protected boolean removeEldestEntry(Map.Entry eldest) { @@ -102,7 +105,7 @@ public SourceKey sourceKey(AgentEvent event) { public Observation observe(AgentEvent event) { Objects.requireNonNull(event, "event"); SourceKey sourceKey = sourceKey(event); - ReplyState state = states.get(sourceKey); + ReplyState state = state(sourceKey); ReplySnapshot before = state != null ? state.snapshot() : ReplyState.emptySnapshot(); EventKind kind = eventKind(event); String eventReplyId = replyId(event); @@ -115,7 +118,7 @@ public Observation observe(AgentEvent event) { case MODEL_CALL_START -> { if (state == null) { state = new ReplyState(); - states.put(sourceKey, state); + storeState(sourceKey, state); } state.replyId = eventReplyId; state.textSeen = false; @@ -152,12 +155,12 @@ public Observation observe(AgentEvent event) { } public ReplySnapshot snapshot(SourceKey sourceKey) { - ReplyState state = states.get(sourceKey); + ReplyState state = state(sourceKey); return state == null ? ReplyState.emptySnapshot() : state.snapshot(); } public void markDispositionEmitted(SourceKey sourceKey) { - ReplyState state = states.get(sourceKey); + ReplyState state = state(sourceKey); if (state != null) { state.dispositionEmitted = true; } @@ -170,11 +173,11 @@ public void markDispositionEmitted(SourceKey sourceKey) { * of the supported API surface. */ public int trackedSourceCount() { - return states.size(); + return (topLevelState != null ? 1 : 0) + childStates.size(); } public void clearReply(SourceKey sourceKey) { - ReplyState state = states.get(sourceKey); + ReplyState state = state(sourceKey); if (state != null) { state.replyId = null; state.textSeen = false; @@ -184,11 +187,31 @@ public void clearReply(SourceKey sourceKey) { } public void clearSource(SourceKey sourceKey) { - states.remove(sourceKey); + if (sourceKey != null && sourceKey.isTopLevel()) { + topLevelState = null; + } else { + childStates.remove(sourceKey); + } } public void clear() { - states.clear(); + topLevelState = null; + childStates.clear(); + } + + private ReplyState state(SourceKey sourceKey) { + if (sourceKey == null) { + return null; + } + return sourceKey.isTopLevel() ? topLevelState : childStates.get(sourceKey); + } + + private void storeState(SourceKey sourceKey, ReplyState state) { + if (sourceKey.isTopLevel()) { + topLevelState = state; + } else { + childStates.put(sourceKey, state); + } } private static EventKind eventKind(AgentEvent event) { diff --git a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java index b2a58c106e..830f623426 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java @@ -30,6 +30,7 @@ import io.agentscope.core.message.MsgRole; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.IntStream; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; @@ -340,6 +341,45 @@ void emitsTopLevelTerminalAndEndBeforeSourceCompletes() { source.assertCancelled(); } + @Test + void retainsTopLevelReplyWhenChildFanOutExceedsTrackedSourceCapacity() { + AgentEndEvent end = new AgentEndEvent("reply-top"); + List fanOut = + IntStream.rangeClosed(0, 4096) + .mapToObj( + i -> + (AgentEvent) + new ModelCallStartEvent("reply-" + i) + .withSource("source-" + i)) + .toList(); + + List events = + AgentEventStreams.withTextOutputDisposition( + Flux.concat( + Flux.just(new ModelCallStartEvent("reply-top")), + Flux.fromIterable(fanOut), + Flux.just( + new TextBlockDeltaEvent( + "reply-top", "block-top", "answer"), + result(GenerateReason.MODEL_STOP), + end))) + .collectList() + .block(); + + TextOutputDispositionEvent terminal = + events.stream() + .filter(TextOutputDispositionEvent.class::isInstance) + .map(TextOutputDispositionEvent.class::cast) + .filter( + disposition -> + disposition.getDisposition() + == TextOutputDisposition.TERMINAL) + .findFirst() + .orElseThrow(); + assertEquals("reply-top", terminal.getReplyId()); + assertSame(end, events.get(events.size() - 1)); + } + @Test void forwardsLateEventsWithoutFailingOrClassifyingThem() { AgentEndEvent end = new AgentEndEvent("reply-1"); diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java index 69a7a668ac..4d5f31cf53 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java @@ -282,7 +282,7 @@ public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) { for (AguiMessage message : runInput.getMessages()) { if (message != null && !isGeneratedTextSegmentId(message.getId()) - && (!hasAuthoritativeMessages || "user".equals(message.getRole()))) { + && (!hasAuthoritativeMessages || message.isUserMessage())) { messagesById.putIfAbsent(message.getId(), message); } } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java index 33568d698e..e2eb9b5528 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java @@ -681,7 +681,7 @@ void testFinalSnapshotUsesSessionMessagesAndResultDeduplicatedById() { } @Test - void testFinalSnapshotKeepsSubmittedTurnWhenAgentStateDoesNotEchoIt() { + void testFinalSnapshotKeepsSubmittedTurnWithCaseInsensitiveUserRole() { Msg assistantOnly = AssistantMessage.builder() .id("reply-previous") @@ -696,8 +696,19 @@ void testFinalSnapshotKeepsSubmittedTurnWhenAgentStateDoesNotEchoIt() { .content(TextBlock.builder().text("canonical result").build()) .generateReason(GenerateReason.MODEL_STOP) .build(); + RunAgentInput runInput = + inputBuilder() + .messages( + List.of( + AguiMessage.textMessage( + "msg-1", + "User", + "submitted question", + null, + null))) + .build(); - List events = runTerminalDisposition(callerContext, finalResult); + List events = runTerminalDisposition(runInput, callerContext, finalResult); assertEquals( List.of("reply-previous", "msg-1", "reply-final"), From 03dbdf9bfc5894e5fe58849a00c8b693fd3b69c4 Mon Sep 17 00:00:00 2001 From: dargoner <1793850+dargoner@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:00:58 +0800 Subject: [PATCH 22/22] =?UTF-8?q?fix(streaming):=20=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E5=AD=90=E6=9D=A5=E6=BA=90=E7=8A=B6=E6=80=81=E4=B8=8E=20AG-UI?= =?UTF-8?q?=20=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 明确子来源容量并校验状态键,落实机器人复审意见 - AG-UI 按 source 与 taskId 统一识别子事件,避免污染父级最终快照 - 增加 taskId-only 子事件和状态容量回归测试 --- .../stream/ReplyLifecycleTracker.java | 8 +++-- .../stream/ReplyLifecycleTrackerTest.java | 4 +-- .../core/agui/adapter/AguiAdapterConfig.java | 12 ++++---- .../strategy/AgentEventConverterRegistry.java | 8 ++--- .../adapter/strategy/AguiStreamContext.java | 20 +++++++++++-- .../strategy/SubagentEventConverter.java | 6 ++-- .../agui/adapter/AguiAgentAdapterV2Test.java | 29 +++++++++++++++++++ .../strategy/SubagentEventConverterTest.java | 16 ++++++++++ 8 files changed, 83 insertions(+), 20 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java index 835ba6fb51..d237d4bb36 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java +++ b/agentscope-core/src/main/java/io/agentscope/core/internal/stream/ReplyLifecycleTracker.java @@ -41,7 +41,7 @@ */ public final class ReplyLifecycleTracker { - static final int MAX_TRACKED_SOURCES = 4096; + static final int MAX_TRACKED_CHILD_SOURCES = 4096; public enum EventKind { MODEL_CALL_START, @@ -89,7 +89,7 @@ public record Observation( new LinkedHashMap<>() { @Override protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_TRACKED_SOURCES; + return size() > MAX_TRACKED_CHILD_SOURCES; } }; @@ -169,6 +169,9 @@ public void markDispositionEmitted(SourceKey sourceKey) { /** * Number of sources currently holding reply state. * + *

The active top-level reply is tracked outside the bounded child-source map, so this count + * can exceed {@link #MAX_TRACKED_CHILD_SOURCES} by one. + * *

Public only so internal stream annotators can account for all retained bookkeeping. Not part * of the supported API surface. */ @@ -207,6 +210,7 @@ private ReplyState state(SourceKey sourceKey) { } private void storeState(SourceKey sourceKey, ReplyState state) { + Objects.requireNonNull(sourceKey, "sourceKey"); if (sourceKey.isTopLevel()) { topLevelState = state; } else { diff --git a/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java index 83f0dffe66..9594649ec0 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java @@ -141,13 +141,13 @@ void eventsThatCannotEstablishAConversationDoNotCreateTrackedState() { } @Test - void capsTrackedSources() { + void capsTrackedChildSources() { ReplyLifecycleTracker tracker = new ReplyLifecycleTracker(); for (int i = 0; i < 4097; i++) { tracker.observe(new ModelCallStartEvent("reply-" + i).withSource("source-" + i)); } - assertEquals(4096, tracker.trackedSourceCount()); + assertEquals(ReplyLifecycleTracker.MAX_TRACKED_CHILD_SOURCES, tracker.trackedSourceCount()); } } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java index ea464494fc..1df38613bf 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAdapterConfig.java @@ -187,9 +187,9 @@ public boolean isBaseEventPropertiesEnricherEnabled() { } /** - * When {@code false} (default), AgentEvents with a non-null {@code source} (subagent events) - * are emitted as AG-UI {@code CUSTOM} events under the {@code subagent.*} namespace instead of - * native {@code TEXT_MESSAGE_*} / run lifecycle events. + * When {@code false} (default), AgentEvents with a non-blank {@code source} or task id (subagent + * events) are emitted as AG-UI {@code CUSTOM} events under the {@code subagent.*} namespace + * instead of native {@code TEXT_MESSAGE_*} / run lifecycle events. * * @return true to keep the legacy native presentation for subagent events */ @@ -424,9 +424,9 @@ public Builder baseEventPropertiesEnricherEnabled( /** * Set whether subagent-sourced events should use native AG-UI event types. * - *

Default is {@code false}: subagent events become {@code CUSTOM} events named {@code - * subagent.*}. Set {@code true} to restore the previous behavior where child text and - * lifecycle events map to the same AG-UI types as the parent. + *

Default is {@code false}: events with a non-blank source or task id become {@code + * CUSTOM} events named {@code subagent.*}. Set {@code true} to restore the previous behavior + * where child text and lifecycle events map to the same AG-UI types as the parent. * * @param emitSubagentEventsAsNative true for legacy native presentation * @return This builder diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java index 5b2460de0a..4c2c5058a9 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java @@ -59,8 +59,8 @@ public AgentEventConverterRegistry( * @param customConverters converters registered after built-in converters * @param enrichers enrichers applied after each conversion * @param emitSubagentEventsAsNative when {@code true}, child events use the same converters as - * the parent; when {@code false} (default), {@code source != null} events become {@code - * subagent.*} CUSTOM / RAW events + * the parent; when {@code false} (default), events with a non-blank source or task id become + * {@code subagent.*} CUSTOM / RAW events */ public AgentEventConverterRegistry( List customConverters, @@ -97,9 +97,7 @@ public List convert(AgentEvent event, AguiStreamContext context) { Objects.requireNonNull(event, "event cannot be null"); Objects.requireNonNull(context, "context cannot be null"); context.beginEvent(); - if (!emitSubagentEventsAsNative - && event.getSource() != null - && !event.getSource().isBlank()) { + if (!emitSubagentEventsAsNative && !context.isTopLevelEvent(event)) { subagentConverter.convert(event, context); } else { converters.getOrDefault(event.getClass(), rawConverter).convert(event, context); diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java index 4d5f31cf53..3c1bad6de5 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java @@ -175,11 +175,27 @@ public void emit(AguiEvent event) { } public void observe(AgentEvent event) { - if (event instanceof AgentResultEvent resultEvent && isBlank(event.getSource())) { + if (event instanceof AgentResultEvent resultEvent && isTopLevelEvent(event)) { finalResult = resultEvent.getResult(); } } + /** + * Whether an event belongs to the parent invocation. A blank source with a task id still belongs + * to a child source, matching the correlation key used by the core event stream. + */ + boolean isTopLevelEvent(AgentEvent event) { + Objects.requireNonNull(event, "event"); + if (!isBlank(event.getSource())) { + return false; + } + Object taskId = + event.getMetadata() == null + ? null + : event.getMetadata().get(AgentEvent.METADATA_TASK_ID); + return taskId == null || taskId.toString().isBlank(); + } + TokenUsageAccumulator getTokenUsageAccumulator() { return tokenUsageAccumulator; } @@ -259,7 +275,7 @@ public void emitTextOutputDisposition(TextOutputDispositionEvent dispositionEven public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) { if (!config.isTextOutputDispositionEnabled() - || !isBlank(endEvent.getSource()) + || !isTopLevelEvent(endEvent) || finalResult == null || !FINAL_SNAPSHOT_REASONS.contains(finalResult.getGenerateReason()) || !pendingInterrupts.isEmpty()) { diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java index 70fe89c18c..e95ceebc1f 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverter.java @@ -40,9 +40,9 @@ import java.util.Set; /** - * Converts subagent-sourced {@link AgentEvent}s ({@code source != null}) into AG-UI {@code CUSTOM} - * events under the {@code subagent.*} name namespace so they do not pollute the parent run lifecycle - * or text stream. + * Converts subagent-sourced {@link AgentEvent}s (a non-blank {@code source} or task id) into AG-UI + * {@code CUSTOM} events under the {@code subagent.*} name namespace so they do not pollute the + * parent run lifecycle or text stream. */ final class SubagentEventConverter implements AgentEventConverter { diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java index e2eb9b5528..536319444c 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java @@ -762,6 +762,35 @@ void testFinalSnapshotDoesNotLetRunInputOverrideAuthoritativeHistory() { assertEquals("state answer", snapshot.messages().get(1).getTextContent()); } + @Test + void testTaskIdOnlyChildEventsStayOutsideParentLifecycleAndSnapshot() { + Msg childResult = + AssistantMessage.builder() + .id("child-reply") + .content(TextBlock.builder().text("child answer").build()) + .generateReason(GenerateReason.MODEL_STOP) + .build(); + AgentResultEvent childResultEvent = new AgentResultEvent(childResult); + childResultEvent.withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-42"); + AgentEndEvent childEnd = new AgentEndEvent("child-reply"); + childEnd.withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-42"); + + List events = + runReActFlux( + AguiAdapterConfig.builder().textOutputDispositionEnabled(true).build(), + Flux.just(childResultEvent, childEnd, new AgentEndEvent("parent"))); + + assertFalse(events.stream().anyMatch(AguiEvent.MessagesSnapshot.class::isInstance)); + assertEquals( + 2, + events.stream().filter(AguiEvent.Custom.class::isInstance).count(), + "both task-id-only child events must use the subagent converter"); + assertEquals( + 1, + events.stream().filter(AguiEvent.RunFinished.class::isInstance).count(), + "only the parent end should finish the AG-UI run"); + } + @Test void testFinalSnapshotFallsBackToOriginalInputWithoutAgentState() { AguiMessage inputMessage = diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java index c1555140a7..e96d1e78b5 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/SubagentEventConverterTest.java @@ -98,6 +98,22 @@ void subagentEventsDowngradeToCustomByDefault() { assertEquals(SubagentEventConverter.NAME_TEXT, textCustom.name()); } + @Test + void taskIdOnlyEventsDowngradeToCustomByDefault() { + AgentEventConverterRegistry registry = new AgentEventConverterRegistry(); + AguiStreamContext context = + new AguiStreamContext("t1", "r1", AguiAdapterConfig.defaultConfig()); + AgentEndEvent childEnd = new AgentEndEvent("child-reply"); + childEnd.withMetadataEntry(AgentEvent.METADATA_TASK_ID, "task-42"); + + List events = registry.convert(childEnd, context); + + AguiEvent.Custom custom = assertInstanceOf(AguiEvent.Custom.class, events.get(0)); + assertEquals(SubagentEventConverter.NAME_LIFECYCLE, custom.name()); + assertEquals("AGENT_END", value(custom).get("type")); + assertEquals("task-42", value(custom).get("taskId")); + } + @Test void completeSubagentSequencePreservesOrderIdentityAndStructuredPayloads() { AgentEventConverterRegistry registry = new AgentEventConverterRegistry();