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