From 0db3ed7bd2d533ce0ce2102291e9ee0c28bc8244 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/20] =?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
---
.../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 72e56f52c3124b03988c1f08894ca8651d7a0ab0 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/20] =?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
---
.../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 d09f6b5c87554f896a0bbe931003dd219b289fb9 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/20] =?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
---
.../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 3ba05d8565dc9654795deccfd9baa6b062f0c345 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/20] =?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
---
.../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 c788fe9bfa353422280e2a9ac4bb7137e9b39f34 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/20] =?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
---
.../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 0fb5206121c007f5027a912d299c69443a39e925 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/20] =?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
---
.../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 6e3b5a1e2e79d3e24b8307ad4c3af2af0f325ee4 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/20] =?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
---
.../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 61f58ae38cf3bc7b9c01c7c329630369e10e6beb 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/20] =?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
---
.../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 1c8149e45c41d2722b862b8ca83f7c77062ed83a 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/20] =?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
---
.../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 11c041b279..de2663ef9d 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;
@@ -58,13 +63,19 @@ public class AguiStreamContext {
private final Set startedToolCalls = new LinkedHashSet<>();
private final Set endedToolCalls = 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);
@@ -92,12 +103,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() {
@@ -130,21 +155,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));
}
}
@@ -152,10 +185,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)) {
@@ -164,10 +205,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) {
String reasoningMessageId = reasoningMessageId(messageId);
if (startedReasoningMessages.add(reasoningMessageId)) {
@@ -331,6 +405,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 62d5a1dfe2..7f7b198dbc 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 =
@@ -498,6 +713,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 3ab4abe76c169b32cb37abf1d5f05f4cf900c707 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/20] =?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
---
.../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 de2663ef9d..6f7d80fe34 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;
@@ -48,6 +53,12 @@ public class AguiStreamContext {
public static final String REASONING_MESSAGE_ID_SUFFIX = "-reasoning";
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;
@@ -220,26 +231,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) {
@@ -421,6 +445,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 7f7b198dbc..f82924416a 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 =
@@ -722,10 +939,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(
@@ -741,10 +963,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 75d07d69a3f0a1ee013338b04796b6820d55abc1 Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Thu, 3 Sep 2026 15:25:20 +0800
Subject: [PATCH 11/20] =?UTF-8?q?feat(web):=20=E5=A2=9E=E5=8A=A0=E6=96=87?=
=?UTF-8?q?=E6=9C=AC=E5=A4=84=E7=BD=AE=E9=A2=84=E8=A7=88=E4=B8=8E=E7=BB=93?=
=?UTF-8?q?=E6=9E=9C=E6=A0=A1=E5=87=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../web/managed/SessionEventTypes.java | 1 +
.../web/api/DataSessionApiController.java | 9 +-
.../web/managed/SessionEventMapper.java | 203 ++++++++++++---
.../web/managed/SessionEventPreviewBus.java | 21 +-
.../web/managed/SessionTurnRunner.java | 57 +++--
.../web/managed/SessionEventMapperTest.java | 237 +++++++++++++++++-
6 files changed, 471 insertions(+), 57 deletions(-)
diff --git a/agentscope-service/service-common/src/main/java/io/agentscope/builder/web/managed/SessionEventTypes.java b/agentscope-service/service-common/src/main/java/io/agentscope/builder/web/managed/SessionEventTypes.java
index 06046c1131..d1c6a7aeed 100644
--- a/agentscope-service/service-common/src/main/java/io/agentscope/builder/web/managed/SessionEventTypes.java
+++ b/agentscope-service/service-common/src/main/java/io/agentscope/builder/web/managed/SessionEventTypes.java
@@ -60,4 +60,5 @@ private SessionEventTypes() {}
// ---- Stream-only (never persisted) ----
public static final String EVENT_START = "event_start";
public static final String EVENT_DELTA = "event_delta";
+ public static final String EVENT_UPDATE = "event_update";
}
diff --git a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/api/DataSessionApiController.java b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/api/DataSessionApiController.java
index 61b845519f..b5f0245058 100644
--- a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/api/DataSessionApiController.java
+++ b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/api/DataSessionApiController.java
@@ -343,7 +343,14 @@ private static String extractSystemText(Map payload) {
private ServerSentEvent toSse(SessionEventDto dto) {
try {
String json = objectMapper.writeValueAsString(dto);
- return ServerSentEvent.builder().event(dto.type()).data(json).build();
+ String eventType =
+ switch (dto.type()) {
+ case SessionEventTypes.EVENT_START -> SessionEventTypes.EVENT_START;
+ case SessionEventTypes.EVENT_DELTA -> SessionEventTypes.EVENT_DELTA;
+ case SessionEventTypes.EVENT_UPDATE -> SessionEventTypes.EVENT_UPDATE;
+ default -> dto.type();
+ };
+ return ServerSentEvent.builder().event(eventType).data(json).build();
} catch (JsonProcessingException ex) {
return ServerSentEvent.builder().event("error").data("{}").build();
}
diff --git a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventMapper.java b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventMapper.java
index 52148827ec..f10d5cae08 100644
--- a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventMapper.java
+++ b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventMapper.java
@@ -24,6 +24,8 @@
import io.agentscope.core.event.ModelCallEndEvent;
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.event.ThinkingBlockDeltaEvent;
import io.agentscope.core.event.ToolCallDeltaEvent;
import io.agentscope.core.event.ToolCallEndEvent;
@@ -32,11 +34,16 @@
import io.agentscope.core.event.ToolResultEndEvent;
import io.agentscope.core.event.ToolResultTextDeltaEvent;
import io.agentscope.core.message.ContentBlock;
+import io.agentscope.core.message.GenerateReason;
+import io.agentscope.core.message.MessageMetadataKeys;
+import io.agentscope.core.message.Msg;
import io.agentscope.core.message.TextBlock;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Component;
@@ -102,12 +109,24 @@ public PersistedEvent(String type, Map payload) {
}
/**
- * Stream-only preview frame ({@code event_start} / {@code event_delta}).
+ * Stream-only preview frame ({@code event_start}, {@code event_delta} or {@code event_update}).
*
* When {@code delta} is null, callers should emit start only (no delta frame).
*/
public record PreviewFrame(
- String streamType, String targetType, String eventId, String delta) {}
+ String streamType,
+ String targetType,
+ String eventId,
+ String delta,
+ Map attributes) {
+ public PreviewFrame(String streamType, String targetType, String eventId, String delta) {
+ this(streamType, targetType, eventId, delta, Map.of());
+ }
+
+ public PreviewFrame {
+ attributes = attributes != null ? attributes : Map.of();
+ }
+ }
/**
* Maps a harness event. Text/thinking/tool deltas produce preview frames only; complete
@@ -118,7 +137,7 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
if (delta.getDelta() == null || delta.getDelta().isEmpty()) {
return MappingResult.empty();
}
- String eventId = previewIds.messageEventId();
+ String eventId = previewIds.messageEventId(delta.getReplyId());
return MappingResult.previewOnly(
new PreviewFrame(
SessionEventTypes.EVENT_DELTA,
@@ -138,17 +157,33 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
eventId,
thinking.getDelta()));
}
+ if (event instanceof TextOutputDispositionEvent disposition) {
+ String eventId = previewIds.messageEventIdIfPresent(disposition.getReplyId());
+ if (eventId == null) {
+ return MappingResult.empty();
+ }
+ if (disposition.getDisposition() == TextOutputDisposition.INTERMEDIATE) {
+ previewIds.markIntermediate(disposition.getReplyId());
+ }
+ Map attributes = new LinkedHashMap<>();
+ attributes.put("replyId", disposition.getReplyId());
+ attributes.put("disposition", disposition.getDisposition().name());
+ if (disposition.getGenerateReason() != null) {
+ attributes.put("generateReason", disposition.getGenerateReason().name());
+ }
+ return MappingResult.previewOnly(
+ new PreviewFrame(
+ SessionEventTypes.EVENT_UPDATE,
+ SessionEventTypes.AGENT_MESSAGE,
+ eventId,
+ null,
+ attributes));
+ }
if (event instanceof AgentResultEvent result) {
- String text =
- result.getResult() != null && result.getResult().getTextContent() != null
- ? result.getResult().getTextContent()
- : "";
- Map payload = new LinkedHashMap<>();
- payload.put("text", text);
- payload.put("content", List.of(Map.of("type", "text", "text", text)));
- // Reuse the preview id when deltas already streamed for this message.
- String eventId = previewIds.consumeMessageEventId();
- return MappingResult.persist(SessionEventTypes.AGENT_MESSAGE, payload, eventId);
+ if (event.getSource() == null) {
+ previewIds.rememberTopLevelResult(result.getResult());
+ }
+ return MappingResult.empty();
}
if (event instanceof ToolCallStartEvent toolUse) {
previewIds.beginToolUse(toolUse.getToolCallId(), toolUse.getToolCallName());
@@ -232,27 +267,107 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
return MappingResult.persist(
SessionEventTypes.AGENT_TOOL_RESULT, payload, buf.eventId());
}
- if (event instanceof ModelCallStartEvent) {
+ if (event instanceof ModelCallStartEvent modelStart) {
// Opening a model request opens a fresh preview window. The previous window must stay
// readable until then: AgentResultEvent arrives only at the end of the turn and needs
// the last window's id to reconcile with the streamed preview.
- previewIds.resetMessage();
+ previewIds.beginModelCall(modelStart.getReplyId());
previewIds.resetThinking();
return MappingResult.persist(SessionEventTypes.SPAN_MODEL_REQUEST_START, Map.of());
}
if (event instanceof ModelCallEndEvent modelEnd) {
+ previewIds.finishModelCall(modelEnd.getReplyId());
Map payload = new LinkedHashMap<>();
if (modelEnd.getUsage() != null) {
payload.put("usage", modelEnd.getUsage());
}
return MappingResult.persist(SessionEventTypes.SPAN_MODEL_REQUEST_END, payload);
}
- if (event instanceof AgentStartEvent || event instanceof AgentEndEvent) {
+ if (event instanceof AgentEndEvent end) {
+ if (event.getSource() != null) {
+ return MappingResult.empty();
+ }
+ return commitTopLevelResult(end, previewIds);
+ }
+ if (event instanceof AgentStartEvent) {
return MappingResult.empty();
}
return MappingResult.empty();
}
+ private MappingResult commitTopLevelResult(AgentEndEvent end, PreviewIds previewIds) {
+ Msg result = previewIds.takeTopLevelResult();
+ String previewId = previewIds.consumeMessageEventId(end.getReplyId());
+ if (result == null) {
+ return MappingResult.empty();
+ }
+
+ GenerateReason reason = result.getGenerateReason();
+ if (!commitsOrdinaryMessage(reason)) {
+ return MappingResult.empty();
+ }
+
+ if (!hasOutput(result)) {
+ if (previewId == null) {
+ return MappingResult.empty();
+ }
+ Map attributes = new LinkedHashMap<>();
+ attributes.put("authoritative", true);
+ attributes.put("hasOutput", false);
+ attributes.put("generateReason", reason.name());
+ return MappingResult.previewOnly(
+ new PreviewFrame(
+ SessionEventTypes.EVENT_UPDATE,
+ SessionEventTypes.AGENT_MESSAGE,
+ previewId,
+ null,
+ attributes));
+ }
+
+ String eventId = previewId != null ? previewId : PreviewIds.newEventId();
+ return MappingResult.persist(
+ SessionEventTypes.AGENT_MESSAGE, messagePayload(result, reason), eventId);
+ }
+
+ private Map messagePayload(Msg message, GenerateReason reason) {
+ Map messageDto = objectMapper.convertValue(message, MAP_TYPE);
+ Map payload = new LinkedHashMap<>();
+ payload.put("text", message.getTextContent());
+ payload.put("content", messageDto.getOrDefault("content", List.of()));
+ Map metadata =
+ message.getMetadata() != null
+ ? new LinkedHashMap<>(message.getMetadata())
+ : Map.of();
+ payload.put("metadata", metadata);
+ payload.put("generateReason", reason.name());
+ if (metadata.containsKey(MessageMetadataKeys.STRUCTURED_OUTPUT)) {
+ payload.put("structuredOutput", metadata.get(MessageMetadataKeys.STRUCTURED_OUTPUT));
+ }
+ return payload;
+ }
+
+ private static boolean hasOutput(Msg message) {
+ if (message.hasStructuredData()) {
+ return true;
+ }
+ for (ContentBlock block : message.getContent()) {
+ if (block instanceof TextBlock text) {
+ if (!text.getText().isEmpty()) {
+ return true;
+ }
+ } else {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean commitsOrdinaryMessage(GenerateReason reason) {
+ return reason == GenerateReason.MODEL_STOP
+ || reason == GenerateReason.STRUCTURED_OUTPUT
+ || reason == GenerateReason.MAX_ITERATIONS;
+ }
+
private Map parseToolInput(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
@@ -277,23 +392,55 @@ private static String stringifyContentBlock(ContentBlock block) {
/** Allocates stable preview / persist event ids for a turn and accumulates tool buffers. */
public static final class PreviewIds {
- private String messageId;
+ private final Map messageIdsByReply = new LinkedHashMap<>();
+ private final Set intermediateReplies = new HashSet<>();
private String thinkingId;
+ private Msg topLevelResult;
private final Map toolUses = new LinkedHashMap<>();
private final Map toolResults = new LinkedHashMap<>();
- public String messageEventId() {
- if (messageId == null) {
- messageId = newEventId();
+ public String messageEventId(String replyId) {
+ return messageIdsByReply.computeIfAbsent(key(replyId), ignored -> newEventId());
+ }
+
+ public String messageEventIdIfPresent(String replyId) {
+ return messageIdsByReply.get(key(replyId));
+ }
+
+ /** Returns and clears the preview id for {@code replyId} (null when no deltas streamed). */
+ public String consumeMessageEventId(String replyId) {
+ String replyKey = key(replyId);
+ intermediateReplies.remove(replyKey);
+ return messageIdsByReply.remove(replyKey);
+ }
+
+ public void markIntermediate(String replyId) {
+ intermediateReplies.add(key(replyId));
+ }
+
+ public void beginModelCall(String replyId) {
+ for (String completed : List.copyOf(intermediateReplies)) {
+ messageIdsByReply.remove(completed);
+ intermediateReplies.remove(completed);
}
- return messageId;
+ messageIdsByReply.remove(key(replyId));
}
- /** Returns and clears the in-flight message preview id (null when no deltas streamed). */
- public String consumeMessageEventId() {
- String id = messageId;
- messageId = null;
- return id;
+ public void finishModelCall(String replyId) {
+ String replyKey = key(replyId);
+ if (intermediateReplies.remove(replyKey)) {
+ messageIdsByReply.remove(replyKey);
+ }
+ }
+
+ public void rememberTopLevelResult(Msg result) {
+ topLevelResult = result;
+ }
+
+ public Msg takeTopLevelResult() {
+ Msg result = topLevelResult;
+ topLevelResult = null;
+ return result;
}
public String thinkingEventId() {
@@ -353,10 +500,6 @@ public ToolBuffers.ToolResultBuffer finishToolResult(String toolCallId, String t
return buf;
}
- public void resetMessage() {
- messageId = null;
- }
-
public void resetThinking() {
thinkingId = null;
}
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..0d41dc14b3 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
@@ -24,8 +24,9 @@
import reactor.core.publisher.Sinks;
/**
- * Process-local stream-only preview bus for {@code event_start} / {@code event_delta}. Never
- * persisted; best-effort and sticky to the turn-owner JVM (see DATA_PLANE_CONTRACT §3).
+ * Process-local stream-only preview bus for {@code event_start}, {@code event_delta} and {@code
+ * event_update}. Never persisted; best-effort and sticky to the turn-owner JVM (see
+ * DATA_PLANE_CONTRACT §3).
*/
@Component
public class SessionEventPreviewBus {
@@ -50,6 +51,18 @@ public void emitDelta(String sessionId, String targetType, String eventId, Strin
emit(sessionId, SessionEventTypes.EVENT_DELTA, payload);
}
+ /** Emits an {@code event_update} frame with lifecycle or authoritative result attributes. */
+ public void emitUpdate(
+ String sessionId, String targetType, String eventId, Map attributes) {
+ Map payload = new LinkedHashMap<>();
+ payload.put("event_id", eventId);
+ payload.put("type", targetType);
+ if (attributes != null) {
+ payload.putAll(attributes);
+ }
+ emit(sessionId, SessionEventTypes.EVENT_UPDATE, payload);
+ }
+
/** Publishes a mapper preview frame when the subscriber opted into {@code targetType}. */
public void emitFrame(
String sessionId, SessionEventMapper.PreviewFrame frame, Set enabledTypes) {
@@ -58,8 +71,10 @@ public void emitFrame(
}
if (SessionEventTypes.EVENT_START.equals(frame.streamType())) {
emitStart(sessionId, frame.targetType(), frame.eventId());
- } else {
+ } else if (SessionEventTypes.EVENT_DELTA.equals(frame.streamType())) {
emitDelta(sessionId, frame.targetType(), frame.eventId(), frame.delta());
+ } else if (SessionEventTypes.EVENT_UPDATE.equals(frame.streamType())) {
+ emitUpdate(sessionId, frame.targetType(), frame.eventId(), frame.attributes());
}
}
diff --git a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionTurnRunner.java b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionTurnRunner.java
index 68fdfe3cbb..dd4bea8f35 100644
--- a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionTurnRunner.java
+++ b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionTurnRunner.java
@@ -25,6 +25,7 @@
import io.agentscope.builder.web.managed.service.SessionEventLog;
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.message.GenerateReason;
import io.agentscope.core.message.Msg;
@@ -270,7 +271,8 @@ private void runTurn(
.build()));
RuntimeContext rc = rcBuilder.build();
- Flux stream = agent.streamEvents(inputMsgs, rc);
+ Flux stream =
+ AgentEventStreams.withTextOutputDisposition(agent.streamEvents(inputMsgs, rc));
previewIdsBySession.put(session.id(), new SessionEventMapper.PreviewIds());
startedPreviewTypes.put(session.id(), ConcurrentHashMap.newKeySet());
@@ -352,11 +354,14 @@ private void failTurn(ManagedSessionDto session, Throwable error, String code) {
* @return {@code true} when the event indicates {@link GenerateReason#TOOL_SUSPENDED}
*/
private boolean handleAgentEvent(String sessionId, AgentEvent event) {
- if (event instanceof AgentResultEvent result
- && result.getResult() != null
- && result.getResult().getGenerateReason() == GenerateReason.TOOL_SUSPENDED) {
+ boolean suspended =
+ event instanceof AgentResultEvent result
+ && event.getSource() == null
+ && result.getResult() != null
+ && result.getResult().getGenerateReason() == GenerateReason.TOOL_SUSPENDED;
+ if (suspended) {
+ AgentResultEvent result = (AgentResultEvent) event;
persistSuspendedToolUses(sessionId, result.getResult());
- return true;
}
SessionEventMapper.PreviewIds ids =
@@ -369,17 +374,35 @@ private boolean handleAgentEvent(String sessionId, AgentEvent event) {
Set started =
startedPreviewTypes.computeIfAbsent(
sessionId, ignored -> ConcurrentHashMap.newKeySet());
- if (started.add(frame.targetType() + ":" + frame.eventId())) {
- previewBus.emitStart(
- sessionId, frame.targetType(), frame.eventId());
- }
- // null delta = start-only announcement (e.g. tool_use begin)
- if (frame.delta() != null) {
- previewBus.emitDelta(
- sessionId,
- frame.targetType(),
- frame.eventId(),
- frame.delta());
+ String previewKey = frame.targetType() + ":" + frame.eventId();
+ switch (frame.streamType()) {
+ case SessionEventTypes.EVENT_START -> {
+ if (started.add(previewKey)) {
+ previewBus.emitStart(
+ sessionId, frame.targetType(), frame.eventId());
+ }
+ }
+ case SessionEventTypes.EVENT_DELTA -> {
+ if (started.add(previewKey)) {
+ previewBus.emitStart(
+ sessionId, frame.targetType(), frame.eventId());
+ }
+ previewBus.emitDelta(
+ sessionId,
+ frame.targetType(),
+ frame.eventId(),
+ frame.delta());
+ }
+ case SessionEventTypes.EVENT_UPDATE ->
+ previewBus.emitUpdate(
+ sessionId,
+ frame.targetType(),
+ frame.eventId(),
+ frame.attributes());
+ default ->
+ log.debug(
+ "Ignoring unsupported preview frame type {}",
+ frame.streamType());
}
});
mapped.persisted()
@@ -390,7 +413,7 @@ private boolean handleAgentEvent(String sessionId, AgentEvent event) {
persisted.type(),
persisted.payload(),
persisted.eventId()));
- return false;
+ return suspended;
}
private void persistSuspendedToolUses(String sessionId, Msg result) {
diff --git a/agentscope-service/service-dataplane/src/test/java/io/agentscope/builder/web/managed/SessionEventMapperTest.java b/agentscope-service/service-dataplane/src/test/java/io/agentscope/builder/web/managed/SessionEventMapperTest.java
index a1a434965d..a1e084d4c0 100644
--- a/agentscope-service/service-dataplane/src/test/java/io/agentscope/builder/web/managed/SessionEventMapperTest.java
+++ b/agentscope-service/service-dataplane/src/test/java/io/agentscope/builder/web/managed/SessionEventMapperTest.java
@@ -16,24 +16,42 @@
package io.agentscope.builder.web.managed;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
+import io.agentscope.builder.web.api.DataSessionApiController;
+import io.agentscope.builder.web.managed.service.HandsMetrics;
+import io.agentscope.builder.web.managed.service.SessionEventLog;
+import io.agentscope.builder.web.toolbus.ToolConfirmationCoordinator;
+import io.agentscope.core.event.AgentEndEvent;
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.TextOutputDisposition;
+import io.agentscope.core.event.TextOutputDispositionEvent;
import io.agentscope.core.event.ThinkingBlockDeltaEvent;
import io.agentscope.core.event.ToolCallDeltaEvent;
import io.agentscope.core.event.ToolCallEndEvent;
import io.agentscope.core.event.ToolCallStartEvent;
import io.agentscope.core.event.ToolResultEndEvent;
import io.agentscope.core.event.ToolResultTextDeltaEvent;
+import io.agentscope.core.message.GenerateReason;
+import io.agentscope.core.message.MessageMetadataKeys;
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.List;
import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.http.codec.ServerSentEvent;
+import org.springframework.security.core.Authentication;
+import reactor.core.publisher.Flux;
class SessionEventMapperTest {
@@ -63,19 +81,41 @@ void thinkingBlockDeltaMapsToAgentThinkingPreviewOnly() {
}
@Test
- void agentResultReusesPreviewMessageEventId() {
+ void agentResultIsBufferedUntilAgentEndAndReusesPreviewMessageEventId() {
SessionEventMapper.MappingResult delta =
mapper.map(new TextBlockDeltaEvent("r", "b", "Hel"), previewIds);
String previewId = delta.preview().orElseThrow().eventId();
- Msg msg = Msg.builder().role(MsgRole.ASSISTANT).textContent("Hello").build();
- SessionEventMapper.MappingResult result = mapper.map(new AgentResultEvent(msg), previewIds);
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(
+ TextBlock.builder().text("Hello").build(),
+ TextBlock.builder().text("world").build())
+ .metadata(Map.of("trace", "kept"))
+ .generateReason(GenerateReason.MODEL_STOP)
+ .build();
+ SessionEventMapper.MappingResult buffered =
+ mapper.map(new AgentResultEvent(msg), previewIds);
+
+ assertThat(buffered.persisted()).isEmpty();
+ assertThat(buffered.preview()).isEmpty();
+
+ SessionEventMapper.MappingResult result = mapper.map(new AgentEndEvent("r"), previewIds);
assertThat(result.preview()).isEmpty();
assertThat(result.persisted()).isPresent();
SessionEventMapper.PersistedEvent persisted = result.persisted().get();
assertThat(persisted.type()).isEqualTo(SessionEventTypes.AGENT_MESSAGE);
- assertThat(persisted.payload().get("text")).isEqualTo("Hello");
+ assertThat(persisted.payload().get("text")).isEqualTo("Hello\nworld");
+ List> content = (List>) persisted.payload().get("content");
+ assertThat(content).hasSize(2);
+ assertThat(((Map, ?>) content.get(0)).get("type")).isEqualTo("text");
+ assertThat(((Map, ?>) content.get(0)).get("text")).isEqualTo("Hello");
+ assertThat(((Map, ?>) content.get(1)).get("text")).isEqualTo("world");
+ assertThat(((Map, ?>) persisted.payload().get("metadata")).get("trace"))
+ .isEqualTo("kept");
+ assertThat(persisted.payload().get("generateReason")).isEqualTo("MODEL_STOP");
assertThat(persisted.eventId()).isEqualTo(previewId);
}
@@ -93,7 +133,8 @@ void agentResultReusesPreviewMessageEventIdAcrossModelCallEnd() {
mapper.map(new ModelCallEndEvent("r", null), previewIds);
Msg msg = Msg.builder().role(MsgRole.ASSISTANT).textContent("Hello").build();
- SessionEventMapper.MappingResult result = mapper.map(new AgentResultEvent(msg), previewIds);
+ assertThat(mapper.map(new AgentResultEvent(msg), previewIds).persisted()).isEmpty();
+ SessionEventMapper.MappingResult result = mapper.map(new AgentEndEvent("r"), previewIds);
assertThat(result.persisted().orElseThrow().eventId()).isEqualTo(previewId);
}
@@ -120,11 +161,195 @@ void multiRoundTurnReusesLastRoundPreviewMessageEventId() {
assertThat(secondRoundId).isNotEqualTo(firstRoundId);
Msg msg = Msg.builder().role(MsgRole.ASSISTANT).textContent("answer").build();
- SessionEventMapper.MappingResult result = mapper.map(new AgentResultEvent(msg), previewIds);
+ assertThat(mapper.map(new AgentResultEvent(msg), previewIds).persisted()).isEmpty();
+ SessionEventMapper.MappingResult result = mapper.map(new AgentEndEvent("r"), previewIds);
assertThat(result.persisted().orElseThrow().eventId()).isEqualTo(secondRoundId);
}
+ @Test
+ void intermediateDispositionUpdatesPreviewWithoutPersistingAndKeepsReplyId() {
+ String previewId =
+ mapper.map(new TextBlockDeltaEvent("r", "b-1", "working"), previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+
+ SessionEventMapper.MappingResult disposition =
+ mapper.map(
+ new TextOutputDispositionEvent(
+ "r", TextOutputDisposition.INTERMEDIATE, null),
+ previewIds);
+
+ assertThat(disposition.persisted()).isEmpty();
+ SessionEventMapper.PreviewFrame update = disposition.preview().orElseThrow();
+ assertThat(update.streamType()).isEqualTo(SessionEventTypes.EVENT_UPDATE);
+ assertThat(update.eventId()).isEqualTo(previewId);
+ assertThat(update.attributes()).containsEntry("disposition", "INTERMEDIATE");
+
+ mapper.map(new ToolCallStartEvent("r", "tool-1", "bash"), previewIds);
+ String afterToolId =
+ mapper.map(new TextBlockDeltaEvent("r", "b-2", "still working"), previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+ assertThat(afterToolId).isEqualTo(previewId);
+ }
+
+ @Test
+ void terminalDispositionOnlyClosesPreviewAndAgentEndCommitsResult() {
+ String previewId =
+ mapper.map(new TextBlockDeltaEvent("r", "b", "preview"), previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .textContent("authoritative")
+ .generateReason(GenerateReason.MODEL_STOP)
+ .build();
+ mapper.map(new AgentResultEvent(msg), previewIds);
+
+ SessionEventMapper.MappingResult terminal =
+ mapper.map(
+ new TextOutputDispositionEvent(
+ "r", TextOutputDisposition.TERMINAL, GenerateReason.MODEL_STOP),
+ previewIds);
+
+ assertThat(terminal.persisted()).isEmpty();
+ assertThat(terminal.preview().orElseThrow().eventId()).isEqualTo(previewId);
+ assertThat(terminal.preview().orElseThrow().attributes())
+ .containsEntry("disposition", "TERMINAL")
+ .containsEntry("generateReason", "MODEL_STOP");
+
+ SessionEventMapper.MappingResult end = mapper.map(new AgentEndEvent("r"), previewIds);
+ assertThat(end.persisted().orElseThrow().payload().get("text")).isEqualTo("authoritative");
+ assertThat(end.persisted().orElseThrow().eventId()).isEqualTo(previewId);
+ }
+
+ @Test
+ void structuredOnlyResultGetsFreshPersistedEventIdAndKeepsStructuredOutput() {
+ Map structured = Map.of("city", "Hangzhou", "temperature", 28);
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .metadata(Map.of(MessageMetadataKeys.STRUCTURED_OUTPUT, structured))
+ .generateReason(GenerateReason.STRUCTURED_OUTPUT)
+ .build();
+
+ assertThat(mapper.map(new AgentResultEvent(msg), previewIds).persisted()).isEmpty();
+ SessionEventMapper.PersistedEvent persisted =
+ mapper.map(new AgentEndEvent("structured-reply"), previewIds)
+ .persisted()
+ .orElseThrow();
+
+ assertThat(persisted.eventId()).startsWith("evt_");
+ assertThat(persisted.payload().get("text")).isEqualTo("");
+ assertThat(persisted.payload().get("content")).isEqualTo(List.of());
+ assertThat(persisted.payload().get("structuredOutput")).isEqualTo(structured);
+ assertThat(persisted.payload().get("generateReason")).isEqualTo("STRUCTURED_OUTPUT");
+ }
+
+ @Test
+ void authoritativeEmptyResultSendsUpdateThatRemovesPreview() {
+ String previewId =
+ mapper.map(new TextBlockDeltaEvent("r", "b", "stale preview"), previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+ Msg empty =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .textContent("")
+ .generateReason(GenerateReason.MODEL_STOP)
+ .build();
+ mapper.map(new AgentResultEvent(empty), previewIds);
+ mapper.map(
+ new TextOutputDispositionEvent(
+ "r", TextOutputDisposition.TERMINAL, GenerateReason.MODEL_STOP),
+ previewIds);
+
+ SessionEventMapper.MappingResult end = mapper.map(new AgentEndEvent("r"), previewIds);
+
+ assertThat(end.persisted()).isEmpty();
+ SessionEventMapper.PreviewFrame update = end.preview().orElseThrow();
+ assertThat(update.streamType()).isEqualTo(SessionEventTypes.EVENT_UPDATE);
+ assertThat(update.eventId()).isEqualTo(previewId);
+ assertThat(update.attributes())
+ .containsEntry("authoritative", true)
+ .containsEntry("hasOutput", false);
+ }
+
+ @Test
+ void suspendedAndPausedResultsDoNotCommitOrdinaryAgentMessage() {
+ for (GenerateReason reason :
+ List.of(
+ GenerateReason.PERMISSION_ASKING,
+ GenerateReason.TOOL_SUSPENDED,
+ GenerateReason.MIDDLEWARE_STOP_REQUESTED)) {
+ SessionEventMapper.PreviewIds ids = new SessionEventMapper.PreviewIds();
+ Msg msg =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .textContent("not a final answer")
+ .generateReason(reason)
+ .build();
+
+ assertThat(mapper.map(new AgentResultEvent(msg), ids).persisted()).isEmpty();
+ assertThat(mapper.map(new AgentEndEvent("r-" + reason), ids).persisted())
+ .as(reason.name())
+ .isEmpty();
+ }
+ }
+
+ @Test
+ void previewBusAndControllerPassUpdateAttributesThroughToSse() throws Exception {
+ SessionEventPreviewBus previewBus = new SessionEventPreviewBus();
+ DataSessionService sessionService = mock(DataSessionService.class);
+ SessionEventLog eventLog = mock(SessionEventLog.class);
+ Authentication authentication = mock(Authentication.class);
+ when(authentication.getPrincipal()).thenReturn("user-1");
+ when(eventLog.subscribe("session-1", 0L)).thenReturn(Flux.never());
+
+ DataSessionApiController controller =
+ new DataSessionApiController(
+ sessionService,
+ eventLog,
+ previewBus,
+ mock(ToolConfirmationCoordinator.class),
+ mock(SessionTurnRunner.class),
+ new ObjectMapper(),
+ mock(HandsMetrics.class));
+ CompletableFuture> next =
+ controller
+ .streamEvents(
+ "session-1",
+ null,
+ List.of(SessionEventTypes.AGENT_MESSAGE),
+ authentication)
+ .next()
+ .toFuture();
+
+ previewBus.emitUpdate(
+ "session-1",
+ SessionEventTypes.AGENT_MESSAGE,
+ "evt_preview",
+ Map.of("disposition", "INTERMEDIATE", "custom", Map.of("nested", true)));
+
+ ServerSentEvent sse = next.get(5, TimeUnit.SECONDS);
+ assertThat(sse.event()).isEqualTo(SessionEventTypes.EVENT_UPDATE);
+ @SuppressWarnings("unchecked")
+ Map json = new ObjectMapper().readValue(sse.data(), Map.class);
+ @SuppressWarnings("unchecked")
+ Map payload = (Map) json.get("payload");
+ assertThat(payload)
+ .containsEntry("event_id", "evt_preview")
+ .containsEntry("type", SessionEventTypes.AGENT_MESSAGE)
+ .containsEntry("disposition", "INTERMEDIATE");
+ assertThat(((Map, ?>) payload.get("custom")).get("nested")).isEqualTo(true);
+ }
+
@Test
void toolCallAccumulatesInputAndPersistsOnEnd() {
assertThat(mapper.map(new ToolCallStartEvent("r", "tool-1", "bash"), previewIds).preview())
From e1083be203d9e5978a8f811e75f9bf928363836d Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Thu, 3 Sep 2026 15:59:55 +0800
Subject: [PATCH 12/20] =?UTF-8?q?fix(web):=20=E9=9A=94=E7=A6=BB=E9=A2=84?=
=?UTF-8?q?=E8=A7=88=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E5=B9=B6=E5=A4=84?=
=?UTF-8?q?=E7=90=86=E7=A9=BA=E6=9D=83=E5=A8=81=E7=BB=93=E6=9E=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../web/managed/SessionEventMapper.java | 252 +++++++++++-------
.../web/managed/SessionEventMapperTest.java | 170 ++++++++++++
2 files changed, 330 insertions(+), 92 deletions(-)
diff --git a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventMapper.java b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventMapper.java
index f10d5cae08..7f1c72ebbf 100644
--- a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventMapper.java
+++ b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventMapper.java
@@ -137,7 +137,7 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
if (delta.getDelta() == null || delta.getDelta().isEmpty()) {
return MappingResult.empty();
}
- String eventId = previewIds.messageEventId(delta.getReplyId());
+ String eventId = previewIds.messageEventId(delta);
return MappingResult.previewOnly(
new PreviewFrame(
SessionEventTypes.EVENT_DELTA,
@@ -149,7 +149,7 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
if (thinking.getDelta() == null || thinking.getDelta().isEmpty()) {
return MappingResult.empty();
}
- String eventId = previewIds.thinkingEventId();
+ String eventId = previewIds.thinkingEventId(thinking);
return MappingResult.previewOnly(
new PreviewFrame(
SessionEventTypes.EVENT_DELTA,
@@ -158,12 +158,12 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
thinking.getDelta()));
}
if (event instanceof TextOutputDispositionEvent disposition) {
- String eventId = previewIds.messageEventIdIfPresent(disposition.getReplyId());
+ String eventId = previewIds.messageEventIdIfPresent(disposition);
if (eventId == null) {
return MappingResult.empty();
}
if (disposition.getDisposition() == TextOutputDisposition.INTERMEDIATE) {
- previewIds.markIntermediate(disposition.getReplyId());
+ previewIds.markIntermediate(disposition);
}
Map attributes = new LinkedHashMap<>();
attributes.put("replyId", disposition.getReplyId());
@@ -180,14 +180,14 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
attributes));
}
if (event instanceof AgentResultEvent result) {
- if (event.getSource() == null) {
- previewIds.rememberTopLevelResult(result.getResult());
+ if (previewIds.isTopLevel(result)) {
+ previewIds.rememberTopLevelResult(result);
}
return MappingResult.empty();
}
if (event instanceof ToolCallStartEvent toolUse) {
- previewIds.beginToolUse(toolUse.getToolCallId(), toolUse.getToolCallName());
- String eventId = previewIds.toolUseEventId(toolUse.getToolCallId());
+ previewIds.beginToolUse(toolUse);
+ String eventId = previewIds.toolUseEventId(toolUse);
// Start announces the upcoming tool_use; args arrive via deltas and persist on End.
return MappingResult.previewOnly(
new PreviewFrame(
@@ -200,9 +200,8 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
if (toolDelta.getDelta() == null || toolDelta.getDelta().isEmpty()) {
return MappingResult.empty();
}
- previewIds.appendToolInput(
- toolDelta.getToolCallId(), toolDelta.getToolCallName(), toolDelta.getDelta());
- String eventId = previewIds.toolUseEventId(toolDelta.getToolCallId());
+ previewIds.appendToolInput(toolDelta);
+ String eventId = previewIds.toolUseEventId(toolDelta);
return MappingResult.previewOnly(
new PreviewFrame(
SessionEventTypes.EVENT_DELTA,
@@ -211,8 +210,7 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
toolDelta.getDelta()));
}
if (event instanceof ToolCallEndEvent toolEnd) {
- ToolBuffers.ToolUseBuffer buf =
- previewIds.finishToolUse(toolEnd.getToolCallId(), toolEnd.getToolCallName());
+ ToolBuffers.ToolUseBuffer buf = previewIds.finishToolUse(toolEnd);
Map input = parseToolInput(buf.inputJson());
Map payload = new LinkedHashMap<>();
payload.put("id", toolEnd.getToolCallId());
@@ -230,8 +228,7 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
if (textDelta.getDelta() == null || textDelta.getDelta().isEmpty()) {
return MappingResult.empty();
}
- previewIds.appendToolResultText(
- textDelta.getToolCallId(), textDelta.getToolCallName(), textDelta.getDelta());
+ previewIds.appendToolResultText(textDelta);
return MappingResult.empty();
}
if (event instanceof ToolResultDataDeltaEvent dataDelta) {
@@ -239,14 +236,11 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
if (fragment == null || fragment.isEmpty()) {
return MappingResult.empty();
}
- previewIds.appendToolResultText(
- dataDelta.getToolCallId(), dataDelta.getToolCallName(), fragment);
+ previewIds.appendToolResultText(dataDelta, fragment);
return MappingResult.empty();
}
if (event instanceof ToolResultEndEvent toolResult) {
- ToolBuffers.ToolResultBuffer buf =
- previewIds.finishToolResult(
- toolResult.getToolCallId(), toolResult.getToolCallName());
+ ToolBuffers.ToolResultBuffer buf = previewIds.finishToolResult(toolResult);
Map payload = new LinkedHashMap<>();
payload.put("tool_use_id", toolResult.getToolCallId());
payload.put("id", toolResult.getToolCallId());
@@ -271,12 +265,12 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
// Opening a model request opens a fresh preview window. The previous window must stay
// readable until then: AgentResultEvent arrives only at the end of the turn and needs
// the last window's id to reconcile with the streamed preview.
- previewIds.beginModelCall(modelStart.getReplyId());
- previewIds.resetThinking();
+ previewIds.beginModelCall(modelStart);
+ previewIds.resetThinking(modelStart);
return MappingResult.persist(SessionEventTypes.SPAN_MODEL_REQUEST_START, Map.of());
}
if (event instanceof ModelCallEndEvent modelEnd) {
- previewIds.finishModelCall(modelEnd.getReplyId());
+ previewIds.finishModelCall(modelEnd);
Map payload = new LinkedHashMap<>();
if (modelEnd.getUsage() != null) {
payload.put("usage", modelEnd.getUsage());
@@ -284,7 +278,7 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
return MappingResult.persist(SessionEventTypes.SPAN_MODEL_REQUEST_END, payload);
}
if (event instanceof AgentEndEvent end) {
- if (event.getSource() != null) {
+ if (!previewIds.isTopLevel(end)) {
return MappingResult.empty();
}
return commitTopLevelResult(end, previewIds);
@@ -296,12 +290,29 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
}
private MappingResult commitTopLevelResult(AgentEndEvent end, PreviewIds previewIds) {
- Msg result = previewIds.takeTopLevelResult();
- String previewId = previewIds.consumeMessageEventId(end.getReplyId());
- if (result == null) {
+ AgentResultEvent resultEvent = previewIds.takeTopLevelResult(end);
+ String previewId = previewIds.consumeMessageEventId(end);
+ if (resultEvent == null) {
return MappingResult.empty();
}
+ Msg result = resultEvent.getResult();
+ if (result == null) {
+ if (previewId == null) {
+ return MappingResult.empty();
+ }
+ Map attributes = new LinkedHashMap<>();
+ attributes.put("authoritative", true);
+ attributes.put("hasOutput", false);
+ return MappingResult.previewOnly(
+ new PreviewFrame(
+ SessionEventTypes.EVENT_UPDATE,
+ SessionEventTypes.AGENT_MESSAGE,
+ previewId,
+ null,
+ attributes));
+ }
+
GenerateReason reason = result.getGenerateReason();
if (!commitsOrdinaryMessage(reason)) {
return MappingResult.empty();
@@ -392,95 +403,127 @@ private static String stringifyContentBlock(ContentBlock block) {
/** Allocates stable preview / persist event ids for a turn and accumulates tool buffers. */
public static final class PreviewIds {
- private final Map messageIdsByReply = new LinkedHashMap<>();
- private final Set intermediateReplies = new HashSet<>();
- private String thinkingId;
- private Msg topLevelResult;
- private final Map toolUses = new LinkedHashMap<>();
- private final Map toolResults = new LinkedHashMap<>();
+ private record InvocationKey(String source, String taskId) {}
+
+ private record PreviewKey(InvocationKey invocation, String replyId) {}
- public String messageEventId(String replyId) {
- return messageIdsByReply.computeIfAbsent(key(replyId), ignored -> newEventId());
+ private record ToolKey(InvocationKey invocation, String toolCallId) {}
+
+ private final Map messageIdsByReply = new LinkedHashMap<>();
+ private final Set intermediateReplies = new HashSet<>();
+ private final Map thinkingIds = new LinkedHashMap<>();
+ private final Map topLevelResults = new LinkedHashMap<>();
+ private final Map toolUses = new LinkedHashMap<>();
+ private final Map toolResults =
+ new LinkedHashMap<>();
+
+ public String messageEventId(TextBlockDeltaEvent event) {
+ PreviewKey previewKey = previewKey(event, event.getReplyId());
+ return messageIdsByReply.computeIfAbsent(previewKey, ignored -> newEventId());
}
- public String messageEventIdIfPresent(String replyId) {
- return messageIdsByReply.get(key(replyId));
+ public String messageEventIdIfPresent(TextOutputDispositionEvent event) {
+ return messageIdsByReply.get(previewKey(event, event.getReplyId()));
}
- /** Returns and clears the preview id for {@code replyId} (null when no deltas streamed). */
- public String consumeMessageEventId(String replyId) {
- String replyKey = key(replyId);
- intermediateReplies.remove(replyKey);
- return messageIdsByReply.remove(replyKey);
+ /** Returns and clears the preview id for this invocation and reply. */
+ public String consumeMessageEventId(AgentEndEvent event) {
+ PreviewKey previewKey = previewKey(event, event.getReplyId());
+ intermediateReplies.remove(previewKey);
+ return messageIdsByReply.remove(previewKey);
}
- public void markIntermediate(String replyId) {
- intermediateReplies.add(key(replyId));
+ public void markIntermediate(TextOutputDispositionEvent event) {
+ intermediateReplies.add(previewKey(event, event.getReplyId()));
}
- public void beginModelCall(String replyId) {
- for (String completed : List.copyOf(intermediateReplies)) {
- messageIdsByReply.remove(completed);
- intermediateReplies.remove(completed);
+ public void beginModelCall(ModelCallStartEvent event) {
+ InvocationKey invocationKey = invocationKey(event);
+ for (PreviewKey completed : List.copyOf(intermediateReplies)) {
+ if (completed.invocation().equals(invocationKey)) {
+ messageIdsByReply.remove(completed);
+ intermediateReplies.remove(completed);
+ }
}
- messageIdsByReply.remove(key(replyId));
+ messageIdsByReply.remove(previewKey(event, event.getReplyId()));
}
- public void finishModelCall(String replyId) {
- String replyKey = key(replyId);
- if (intermediateReplies.remove(replyKey)) {
- messageIdsByReply.remove(replyKey);
+ public void finishModelCall(ModelCallEndEvent event) {
+ PreviewKey previewKey = previewKey(event, event.getReplyId());
+ if (intermediateReplies.remove(previewKey)) {
+ messageIdsByReply.remove(previewKey);
}
}
- public void rememberTopLevelResult(Msg result) {
- topLevelResult = result;
+ public boolean isTopLevel(AgentEvent event) {
+ return invocationKey(event).equals(new InvocationKey("", ""));
}
- public Msg takeTopLevelResult() {
- Msg result = topLevelResult;
- topLevelResult = null;
- return result;
+ public void rememberTopLevelResult(AgentResultEvent result) {
+ topLevelResults.put(invocationKey(result), result);
}
- public String thinkingEventId() {
- if (thinkingId == null) {
- thinkingId = newEventId();
- }
- return thinkingId;
+ public AgentResultEvent takeTopLevelResult(AgentEndEvent end) {
+ return topLevelResults.remove(invocationKey(end));
+ }
+
+ public String thinkingEventId(ThinkingBlockDeltaEvent event) {
+ return thinkingIds.computeIfAbsent(invocationKey(event), ignored -> newEventId());
}
- public String toolUseEventId(String toolCallId) {
- return beginToolUse(toolCallId, null).eventId();
+ public String toolUseEventId(ToolCallStartEvent event) {
+ return beginToolUse(event).eventId();
}
- public ToolBuffers.ToolUseBuffer beginToolUse(String toolCallId, String toolName) {
- String key = key(toolCallId);
+ public String toolUseEventId(ToolCallDeltaEvent event) {
+ return beginToolUse(event, event.getToolCallId(), event.getToolCallName()).eventId();
+ }
+
+ public ToolBuffers.ToolUseBuffer beginToolUse(ToolCallStartEvent event) {
+ return beginToolUse(event, event.getToolCallId(), event.getToolCallName());
+ }
+
+ private ToolBuffers.ToolUseBuffer beginToolUse(
+ AgentEvent event, String toolCallId, String toolName) {
+ ToolKey toolKey = toolKey(event, toolCallId);
return toolUses.computeIfAbsent(
- key, ignored -> new ToolBuffers.ToolUseBuffer(newEventId(), toolName));
+ toolKey, ignored -> new ToolBuffers.ToolUseBuffer(newEventId(), toolName));
}
- public void appendToolInput(String toolCallId, String toolName, String delta) {
- ToolBuffers.ToolUseBuffer buf = beginToolUse(toolCallId, toolName);
- if (toolName != null) {
- buf.setToolName(toolName);
+ public void appendToolInput(ToolCallDeltaEvent event) {
+ ToolBuffers.ToolUseBuffer buf =
+ beginToolUse(event, event.getToolCallId(), event.getToolCallName());
+ if (event.getToolCallName() != null) {
+ buf.setToolName(event.getToolCallName());
}
- buf.appendInput(delta, MAX_TOOL_PAYLOAD_CHARS);
+ buf.appendInput(event.getDelta(), MAX_TOOL_PAYLOAD_CHARS);
}
- public ToolBuffers.ToolUseBuffer finishToolUse(String toolCallId, String toolName) {
- ToolBuffers.ToolUseBuffer buf = beginToolUse(toolCallId, toolName);
- if (toolName != null) {
- buf.setToolName(toolName);
+ public ToolBuffers.ToolUseBuffer finishToolUse(ToolCallEndEvent event) {
+ ToolKey toolKey = toolKey(event, event.getToolCallId());
+ ToolBuffers.ToolUseBuffer buf =
+ beginToolUse(event, event.getToolCallId(), event.getToolCallName());
+ if (event.getToolCallName() != null) {
+ buf.setToolName(event.getToolCallName());
}
- toolUses.remove(key(toolCallId));
+ toolUses.remove(toolKey);
return buf;
}
- public void appendToolResultText(String toolCallId, String toolName, String delta) {
+ public void appendToolResultText(ToolResultTextDeltaEvent event) {
+ appendToolResultText(
+ event, event.getToolCallId(), event.getToolCallName(), event.getDelta());
+ }
+
+ public void appendToolResultText(ToolResultDataDeltaEvent event, String delta) {
+ appendToolResultText(event, event.getToolCallId(), event.getToolCallName(), delta);
+ }
+
+ private void appendToolResultText(
+ AgentEvent event, String toolCallId, String toolName, String delta) {
ToolBuffers.ToolResultBuffer buf =
toolResults.computeIfAbsent(
- key(toolCallId),
+ toolKey(event, toolCallId),
ignored -> new ToolBuffers.ToolResultBuffer(newEventId(), toolName));
if (toolName != null) {
buf.setToolName(toolName);
@@ -488,24 +531,49 @@ public void appendToolResultText(String toolCallId, String toolName, String delt
buf.appendOutput(delta, MAX_TOOL_PAYLOAD_CHARS);
}
- public ToolBuffers.ToolResultBuffer finishToolResult(String toolCallId, String toolName) {
+ public ToolBuffers.ToolResultBuffer finishToolResult(ToolResultEndEvent event) {
+ ToolKey toolKey = toolKey(event, event.getToolCallId());
ToolBuffers.ToolResultBuffer buf =
toolResults.computeIfAbsent(
- key(toolCallId),
- ignored -> new ToolBuffers.ToolResultBuffer(newEventId(), toolName));
- if (toolName != null) {
- buf.setToolName(toolName);
- }
- toolResults.remove(key(toolCallId));
+ toolKey,
+ ignored ->
+ new ToolBuffers.ToolResultBuffer(
+ newEventId(), event.getToolCallName()));
+ if (event.getToolCallName() != null) {
+ buf.setToolName(event.getToolCallName());
+ }
+ toolResults.remove(toolKey);
return buf;
}
- public void resetThinking() {
- thinkingId = null;
+ public void resetThinking(ModelCallStartEvent event) {
+ thinkingIds.remove(invocationKey(event));
+ }
+
+ private static PreviewKey previewKey(AgentEvent event, String replyId) {
+ return new PreviewKey(invocationKey(event), key(replyId));
+ }
+
+ private static ToolKey toolKey(AgentEvent event, String toolCallId) {
+ return new ToolKey(invocationKey(event), key(toolCallId));
+ }
+
+ private static InvocationKey invocationKey(AgentEvent event) {
+ Object taskId =
+ event.getMetadata() == null
+ ? null
+ : event.getMetadata().get(AgentEvent.METADATA_TASK_ID);
+ return new InvocationKey(
+ normalize(event.getSource()),
+ normalize(taskId == null ? null : taskId.toString()));
+ }
+
+ private static String normalize(String value) {
+ return value == null ? "" : value;
}
- private static String key(String toolCallId) {
- return toolCallId == null || toolCallId.isBlank() ? "_" : toolCallId;
+ private static String key(String value) {
+ return value == null || value.isBlank() ? "_" : value;
}
private static String newEventId() {
diff --git a/agentscope-service/service-dataplane/src/test/java/io/agentscope/builder/web/managed/SessionEventMapperTest.java b/agentscope-service/service-dataplane/src/test/java/io/agentscope/builder/web/managed/SessionEventMapperTest.java
index a1e084d4c0..6aad585c65 100644
--- a/agentscope-service/service-dataplane/src/test/java/io/agentscope/builder/web/managed/SessionEventMapperTest.java
+++ b/agentscope-service/service-dataplane/src/test/java/io/agentscope/builder/web/managed/SessionEventMapperTest.java
@@ -25,6 +25,7 @@
import io.agentscope.builder.web.managed.service.SessionEventLog;
import io.agentscope.builder.web.toolbus.ToolConfirmationCoordinator;
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;
@@ -281,6 +282,171 @@ void authoritativeEmptyResultSendsUpdateThatRemovesPreview() {
.containsEntry("hasOutput", false);
}
+ @Test
+ void nullLastResultStillSendsAuthoritativeEmptyUpdateWhenPreviewExists() {
+ String previewId =
+ mapper.map(new TextBlockDeltaEvent("r", "b", "stale preview"), previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+ Msg nonEmpty =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .textContent("first result")
+ .generateReason(GenerateReason.MODEL_STOP)
+ .build();
+ mapper.map(new AgentResultEvent(nonEmpty), previewIds);
+ mapper.map(new AgentResultEvent(null), previewIds);
+
+ SessionEventMapper.MappingResult end = mapper.map(new AgentEndEvent("r"), previewIds);
+
+ assertThat(end.persisted()).isEmpty();
+ SessionEventMapper.PreviewFrame update = end.preview().orElseThrow();
+ assertThat(update.streamType()).isEqualTo(SessionEventTypes.EVENT_UPDATE);
+ assertThat(update.eventId()).isEqualTo(previewId);
+ assertThat(update.attributes())
+ .containsEntry("authoritative", true)
+ .containsEntry("hasOutput", false)
+ .doesNotContainKey("generateReason");
+ }
+
+ @Test
+ void normalEndWithoutAnyResultDoesNotEmitAuthoritativeEmptyUpdate() {
+ String previewId =
+ mapper.map(new TextBlockDeltaEvent("r", "b", "preview"), previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+
+ SessionEventMapper.MappingResult end = mapper.map(new AgentEndEvent("r"), previewIds);
+
+ assertThat(end.persisted()).isEmpty();
+ assertThat(end.preview()).isEmpty();
+ assertThat(previewId).startsWith("evt_");
+ }
+
+ @Test
+ void previewIdsAreIsolatedBySourceAndTaskIdWhenReplyIdsCollide() {
+ String parentId =
+ mapper.map(new TextBlockDeltaEvent("shared", "b-parent", "parent"), previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+ String childId =
+ mapper.map(
+ scoped(
+ new TextBlockDeltaEvent("shared", "b-child", "child"),
+ "child",
+ "task-1"),
+ previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+
+ assertThat(childId).isNotEqualTo(parentId);
+
+ SessionEventMapper.PreviewFrame childUpdate =
+ mapper.map(
+ scoped(
+ new TextOutputDispositionEvent(
+ "shared", TextOutputDisposition.INTERMEDIATE, null),
+ "child",
+ "task-1"),
+ previewIds)
+ .preview()
+ .orElseThrow();
+ assertThat(childUpdate.eventId()).isEqualTo(childId);
+
+ mapper.map(scoped(new ModelCallStartEvent("shared"), "child", "task-1"), previewIds);
+ String childNextRoundId =
+ mapper.map(
+ scoped(
+ new TextBlockDeltaEvent(
+ "shared", "b-child-next", "child next"),
+ "child",
+ "task-1"),
+ previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+ assertThat(childNextRoundId).isNotEqualTo(childId);
+
+ Msg parentResult =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .textContent("parent answer")
+ .generateReason(GenerateReason.MODEL_STOP)
+ .build();
+ mapper.map(new AgentResultEvent(parentResult), previewIds);
+ SessionEventMapper.PersistedEvent parentPersisted =
+ mapper.map(new AgentEndEvent("shared"), previewIds).persisted().orElseThrow();
+ assertThat(parentPersisted.eventId()).isEqualTo(parentId);
+ assertThat(parentPersisted.payload().get("text")).isEqualTo("parent answer");
+ }
+
+ @Test
+ void previewIdsAreIsolatedByTaskIdWithinSameSourceWhenReplyIdsCollide() {
+ String taskOneId =
+ mapper.map(
+ scoped(
+ new TextBlockDeltaEvent("shared", "b-1", "one"),
+ "worker",
+ "task-1"),
+ previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+ String taskTwoId =
+ mapper.map(
+ scoped(
+ new TextBlockDeltaEvent("shared", "b-2", "two"),
+ "worker",
+ "task-2"),
+ previewIds)
+ .preview()
+ .orElseThrow()
+ .eventId();
+
+ assertThat(taskTwoId).isNotEqualTo(taskOneId);
+
+ SessionEventMapper.PreviewFrame taskOneUpdate =
+ mapper.map(
+ scoped(
+ new TextOutputDispositionEvent(
+ "shared", TextOutputDisposition.INTERMEDIATE, null),
+ "worker",
+ "task-1"),
+ previewIds)
+ .preview()
+ .orElseThrow();
+
+ mapper.map(scoped(new ModelCallStartEvent("shared"), "worker", "task-2"), previewIds);
+
+ SessionEventMapper.PreviewFrame taskTwoNextRound =
+ mapper.map(
+ scoped(
+ new TextBlockDeltaEvent("shared", "b-2-next", "two next"),
+ "worker",
+ "task-2"),
+ previewIds)
+ .preview()
+ .orElseThrow();
+ SessionEventMapper.PreviewFrame taskOneUpdateAfterTaskTwoStarts =
+ mapper.map(
+ scoped(
+ new TextOutputDispositionEvent(
+ "shared", TextOutputDisposition.INTERMEDIATE, null),
+ "worker",
+ "task-1"),
+ previewIds)
+ .preview()
+ .orElseThrow();
+
+ assertThat(taskOneUpdate.eventId()).isEqualTo(taskOneId);
+ assertThat(taskOneUpdateAfterTaskTwoStarts.eventId()).isEqualTo(taskOneId);
+ assertThat(taskTwoNextRound.eventId()).isNotEqualTo(taskTwoId);
+ }
+
@Test
void suspendedAndPausedResultsDoNotCommitOrdinaryAgentMessage() {
for (GenerateReason reason :
@@ -390,4 +556,8 @@ void toolResultAccumulatesOutputAndPersistsOnEnd() {
assertThat(persisted.payload().get("text")).isEqualTo("file1\nfile2\n");
assertThat(persisted.eventId()).startsWith("evt_");
}
+
+ private static AgentEvent scoped(AgentEvent event, String source, String taskId) {
+ return event.withSource(source).withMetadataEntry(AgentEvent.METADATA_TASK_ID, taskId);
+ }
}
From b5e3e868b1529b3130f469fee65deff3483f57b6 Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Thu, 3 Sep 2026 16:22:16 +0800
Subject: [PATCH 13/20] =?UTF-8?q?feat(frontend):=20=E5=8C=BA=E5=88=86?=
=?UTF-8?q?=E8=BF=87=E7=A8=8B=E6=96=87=E6=9C=AC=E4=B8=8E=E6=9D=83=E5=A8=81?=
=?UTF-8?q?=E6=9C=80=E7=BB=88=E7=BB=93=E6=9E=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
agentscope-service/frontend/package-lock.json | 1645 ++++++++++++++++-
agentscope-service/frontend/package.json | 4 +-
.../frontend/src/api/managedSessions.ts | 2 +
.../src/components/ChatPanel.test.tsx | 111 ++
.../frontend/src/components/ChatPanel.tsx | 410 ++--
5 files changed, 2029 insertions(+), 143 deletions(-)
create mode 100644 agentscope-service/frontend/src/components/ChatPanel.test.tsx
diff --git a/agentscope-service/frontend/package-lock.json b/agentscope-service/frontend/package-lock.json
index 52467a429e..85c4deb33d 100644
--- a/agentscope-service/frontend/package-lock.json
+++ b/agentscope-service/frontend/package-lock.json
@@ -20,8 +20,10 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.27.0",
+ "prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-markdown": "^10.1.0",
"react-router-dom": "^6.28.0",
"tailwind-merge": "^3.6.0"
},
@@ -33,7 +35,8 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.3.3",
"typescript": "^5.7.2",
- "vite": "^6.0.3"
+ "vite": "^6.0.3",
+ "vitest": "^3.2.4"
}
},
"node_modules/@babel/code-frame": {
@@ -2297,11 +2300,70 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@types/debug/-/debug-4.1.13.tgz",
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.5",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@types/hast/-/hast-3.0.5.tgz",
+ "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
@@ -2314,18 +2376,22 @@
"undici-types": "~8.3.0"
}
},
+ "node_modules/@types/prismjs": {
+ "version": "1.26.6",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@types/prismjs/-/prismjs-1.26.6.tgz",
+ "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
+ "license": "MIT"
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.29",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
"integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -2342,6 +2408,18 @@
"@types/react": "^18.0.0"
}
},
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.4.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
+ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
+ "license": "ISC"
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2363,6 +2441,121 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "3.2.7",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@vitest/expect/-/expect-3.2.7.tgz",
+ "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.7",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@vitest/mocker/-/mocker-3.2.7.tgz",
+ "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.7",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
+ "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.7",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@vitest/runner/-/runner-3.2.7.tgz",
+ "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.7",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.7",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@vitest/snapshot/-/snapshot-3.2.7.tgz",
+ "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.7",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@vitest/spy/-/spy-3.2.7.tgz",
+ "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.7",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@vitest/utils/-/utils-3.2.7.tgz",
+ "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.anpm.alibaba-inc.com/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -2375,6 +2568,26 @@
"node": ">=10"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
@@ -2422,6 +2635,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
@@ -2443,6 +2666,83 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.anpm.alibaba-inc.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -2464,6 +2764,16 @@
"node": ">=6"
}
},
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2475,14 +2785,12 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2496,6 +2804,38 @@
}
}
},
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.anpm.alibaba-inc.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2512,6 +2852,19 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.361",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz",
@@ -2533,6 +2886,13 @@
"node": ">=10.13.0"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2585,6 +2945,42 @@
"node": ">=6"
}
},
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2644,6 +3040,118 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.anpm.alibaba-inc.com/jiti/-/jiti-2.7.0.tgz",
@@ -2947,7 +3455,17 @@
"url": "https://opencollective.com/parcel"
}
},
- "node_modules/loose-envify": {
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
@@ -2959,6 +3477,13 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2988,11 +3513,605 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -3024,6 +4143,48 @@
"node": ">=18"
}
},
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3073,6 +4234,29 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prism-react-renderer": {
+ "version": "2.4.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz",
+ "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/prismjs": "^1.26.0",
+ "clsx": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.0.0"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -3098,6 +4282,33 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/react-markdown/-/react-markdown-10.1.0.tgz",
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3209,6 +4420,39 @@
}
}
},
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/rollup": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
@@ -3273,6 +4517,13 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3283,6 +4534,82 @@
"node": ">=0.10.0"
}
},
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
"node_modules/tailwind-merge": {
"version": "3.6.0",
"resolved": "https://registry.anpm.alibaba-inc.com/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
@@ -3314,6 +4641,20 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -3331,6 +4672,56 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.anpm.alibaba-inc.com/tslib/-/tslib-2.8.1.tgz",
@@ -3358,6 +4749,93 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -3432,6 +4910,34 @@
}
}
},
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
@@ -3507,12 +5013,135 @@
}
}
},
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.7",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/vitest/-/vitest-3.2.7.tgz",
+ "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.7",
+ "@vitest/mocker": "3.2.7",
+ "@vitest/pretty-format": "^3.2.7",
+ "@vitest/runner": "3.2.7",
+ "@vitest/snapshot": "3.2.7",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.7",
+ "@vitest/ui": "3.2.7",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://maven.rdc.finstone.com.cn/nexus/repository/npm-public/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
}
}
}
diff --git a/agentscope-service/frontend/package.json b/agentscope-service/frontend/package.json
index 2045c7acf4..ebdc78aac1 100644
--- a/agentscope-service/frontend/package.json
+++ b/agentscope-service/frontend/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
+ "test": "vitest run",
"preview": "vite preview",
"lint": "eslint src --ext ts,tsx"
},
@@ -37,6 +38,7 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.3.3",
"typescript": "^5.7.2",
- "vite": "^6.0.3"
+ "vite": "^6.0.3",
+ "vitest": "^3.2.4"
}
}
diff --git a/agentscope-service/frontend/src/api/managedSessions.ts b/agentscope-service/frontend/src/api/managedSessions.ts
index 4ef6a8d61c..abe47775b0 100644
--- a/agentscope-service/frontend/src/api/managedSessions.ts
+++ b/agentscope-service/frontend/src/api/managedSessions.ts
@@ -42,6 +42,8 @@ export interface SessionEvent {
seq: number;
type: string;
payload?: Record;
+ /** Optional event_update metadata; managed-session streams may flatten it into payload. */
+ attributes?: Record;
processedAt?: number | null;
createdAt: number;
}
diff --git a/agentscope-service/frontend/src/components/ChatPanel.test.tsx b/agentscope-service/frontend/src/components/ChatPanel.test.tsx
new file mode 100644
index 0000000000..c265ff18fc
--- /dev/null
+++ b/agentscope-service/frontend/src/components/ChatPanel.test.tsx
@@ -0,0 +1,111 @@
+/*
+ * 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.
+ */
+
+import { describe, expect, it } from 'vitest';
+import type { SessionEvent } from '../api/managedSessions';
+import {
+ applyChatFrame,
+ chatDisplayBlocks,
+ createChatDisplayState,
+} from './ChatPanel';
+
+function frame(
+ id: string,
+ type: string,
+ payload?: Record,
+ attributes?: Record,
+): SessionEvent {
+ return {
+ id,
+ sessionId: 'session-1',
+ seq: -1,
+ type,
+ payload,
+ attributes,
+ createdAt: 1,
+ };
+}
+
+function startFrame(eventId: string, type = 'agent.message'): SessionEvent {
+ return frame(`start-${eventId}`, 'event_start', { event_id: eventId, type });
+}
+
+function deltaFrame(eventId: string, delta: string, type = 'agent.message'): SessionEvent {
+ return frame(`delta-${eventId}`, 'event_delta', { event_id: eventId, type, delta });
+}
+
+function updateFrame(eventId: string, attributes: Record): SessionEvent {
+ return frame(
+ `update-${eventId}`,
+ 'event_update',
+ { event_id: eventId, type: 'agent.message' },
+ attributes,
+ );
+}
+
+function persistedAgentMessage(eventId: string, text: string): SessionEvent {
+ return frame(eventId, 'agent.message', { text });
+}
+
+describe('chat display state', () => {
+ it('moves intermediate preview to commentary and confirms only authoritative result', () => {
+ let state = createChatDisplayState();
+ const applyFrame = (event: SessionEvent) => {
+ state = applyChatFrame(state, event);
+ };
+
+ applyFrame(startFrame('preview-1'));
+ applyFrame(deltaFrame('preview-1', 'working'));
+ applyFrame(updateFrame('preview-1', { disposition: 'INTERMEDIATE' }));
+
+ expect(state.commentarySegments['preview-1'].text).toBe('working');
+ expect(state.pendingSegments['preview-1']).toBeUndefined();
+ expect(state.finalAnswer).toBeUndefined();
+
+ applyFrame(startFrame('preview-2'));
+ applyFrame(deltaFrame('preview-2', 'draft'));
+ applyFrame(persistedAgentMessage('preview-2', 'answer'));
+
+ expect(state.pendingSegments['preview-2']).toBeUndefined();
+ expect(state.finalAnswer?.text).toBe('answer');
+ });
+
+ it('removes an empty authoritative preview without creating a placeholder answer', () => {
+ let state = createChatDisplayState();
+ state = applyChatFrame(state, startFrame('preview-empty'));
+ state = applyChatFrame(state, deltaFrame('preview-empty', 'stale preview'));
+ state = applyChatFrame(state, updateFrame('preview-empty', {
+ authoritative: true,
+ hasOutput: false,
+ }));
+
+ expect(state.pendingSegments['preview-empty']).toBeUndefined();
+ expect(state.finalAnswer).toBeUndefined();
+ expect(chatDisplayBlocks(state).map(block => block.text)).not.toContain('[agent response]');
+ });
+
+ it('keeps thinking and tool execution separate from answer text', () => {
+ let state = createChatDisplayState();
+ state = applyChatFrame(state, deltaFrame('thinking-1', 'checking', 'agent.thinking'));
+ state = applyChatFrame(state, startFrame('tool-1', 'agent.tool_use'));
+ state = applyChatFrame(state, deltaFrame('tool-1', '{"city":', 'agent.tool_use'));
+ state = applyChatFrame(state, deltaFrame('tool-1', '"Hangzhou"}', 'agent.tool_use'));
+
+ expect(state.thinkingSegments['thinking-1'].text).toBe('checking');
+ expect(state.toolExecutions['tool-1'].text).toBe('{"city":"Hangzhou"}');
+ expect(state.finalAnswer).toBeUndefined();
+ });
+});
diff --git a/agentscope-service/frontend/src/components/ChatPanel.tsx b/agentscope-service/frontend/src/components/ChatPanel.tsx
index 898f5655ae..8add7e77c3 100644
--- a/agentscope-service/frontend/src/components/ChatPanel.tsx
+++ b/agentscope-service/frontend/src/components/ChatPanel.tsx
@@ -35,6 +35,7 @@ interface Message {
id: string;
role: Role;
blocks: ContentBlock[];
+ displayState?: ChatDisplayState;
pending?: boolean;
/** Turn finished: no more blocks are appended to this bubble. */
closed?: boolean;
@@ -46,6 +47,201 @@ interface PendingConfirmation {
input?: Record;
}
+export interface ChatTextSegment {
+ id: string;
+ text: string;
+}
+
+export interface ChatToolExecution {
+ id: string;
+ toolName: string;
+ text?: string;
+ result?: string;
+}
+
+export interface ChatDisplayState {
+ pendingSegments: Record;
+ commentarySegments: Record;
+ thinkingSegments: Record;
+ toolExecutions: Record;
+ finalAnswer?: ChatTextSegment;
+}
+
+export function createChatDisplayState(): ChatDisplayState {
+ return {
+ pendingSegments: {},
+ commentarySegments: {},
+ thinkingSegments: {},
+ toolExecutions: {},
+ };
+}
+
+function appendText(
+ segments: Record,
+ id: string,
+ delta: string,
+): Record {
+ return {
+ ...segments,
+ [id]: { id, text: `${segments[id]?.text ?? ''}${delta}` },
+ };
+}
+
+/** Applies one persisted event or stream-only preview frame to the current assistant turn. */
+export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): ChatDisplayState {
+ const payload = event.payload ?? {};
+ const eventId = String(payload.event_id ?? event.id ?? '');
+ const targetType = String(payload.type ?? '');
+
+ if (event.type === 'event_start') {
+ if (!eventId) return state;
+ if (targetType === 'agent.message') {
+ return {
+ ...state,
+ pendingSegments: {
+ ...state.pendingSegments,
+ [eventId]: state.pendingSegments[eventId] ?? { id: eventId, text: '' },
+ },
+ };
+ }
+ if (targetType === 'agent.tool_use') {
+ return {
+ ...state,
+ toolExecutions: {
+ ...state.toolExecutions,
+ [eventId]: state.toolExecutions[eventId]
+ ?? { id: eventId, toolName: 'tool', text: '' },
+ },
+ };
+ }
+ return state;
+ }
+
+ if (event.type === 'event_delta') {
+ const delta = payload.delta != null ? String(payload.delta) : '';
+ if (!eventId || !delta) return state;
+ if (targetType === 'agent.message') {
+ return { ...state, pendingSegments: appendText(state.pendingSegments, eventId, delta) };
+ }
+ if (targetType === 'agent.thinking') {
+ return { ...state, thinkingSegments: appendText(state.thinkingSegments, eventId, delta) };
+ }
+ if (targetType === 'agent.tool_use') {
+ const current = state.toolExecutions[eventId];
+ return {
+ ...state,
+ toolExecutions: {
+ ...state.toolExecutions,
+ [eventId]: {
+ id: eventId,
+ toolName: current?.toolName ?? 'tool',
+ text: `${current?.text ?? ''}${delta}`,
+ result: current?.result,
+ },
+ },
+ };
+ }
+ return state;
+ }
+
+ if (event.type === 'event_update' && targetType === 'agent.message' && eventId) {
+ const attributes = { ...payload, ...(event.attributes ?? {}) };
+ if (attributes.disposition === 'INTERMEDIATE') {
+ const pending = state.pendingSegments[eventId];
+ if (!pending) return state;
+ const pendingSegments = { ...state.pendingSegments };
+ delete pendingSegments[eventId];
+ return {
+ ...state,
+ pendingSegments,
+ commentarySegments: appendText(
+ state.commentarySegments,
+ eventId,
+ pending.text,
+ ),
+ };
+ }
+ if (attributes.authoritative === true && attributes.hasOutput === false) {
+ if (!state.pendingSegments[eventId]) return state;
+ const pendingSegments = { ...state.pendingSegments };
+ delete pendingSegments[eventId];
+ return { ...state, pendingSegments };
+ }
+ return state;
+ }
+
+ if (event.type === 'agent.message') {
+ const text = payloadText(event.payload);
+ const pendingSegments = { ...state.pendingSegments };
+ delete pendingSegments[event.id];
+ return {
+ ...state,
+ pendingSegments,
+ finalAnswer: text ? { id: event.id, text } : undefined,
+ };
+ }
+
+ if (event.type === 'agent.tool_use') {
+ const toolId = String(payload.id ?? payload.toolCallId ?? payload.toolUseId ?? event.id);
+ if (!toolId) return state;
+ const preview = state.toolExecutions[event.id];
+ const toolExecutions = { ...state.toolExecutions };
+ if (event.id !== toolId) delete toolExecutions[event.id];
+ toolExecutions[toolId] = {
+ id: toolId,
+ toolName: String(payload.name ?? payload.toolName ?? preview?.toolName ?? 'tool'),
+ text: preview?.text ?? (payload.input != null ? JSON.stringify(payload.input) : undefined),
+ result: preview?.result,
+ };
+ return { ...state, toolExecutions };
+ }
+
+ if (event.type === 'agent.tool_result') {
+ const toolId = String(payload.tool_use_id ?? payload.toolCallId ?? payload.id ?? '');
+ const current = state.toolExecutions[toolId];
+ if (!toolId || !current) return state;
+ const result = payload.output != null ? String(payload.output) : payloadText(event.payload);
+ return {
+ ...state,
+ toolExecutions: {
+ ...state.toolExecutions,
+ [toolId]: { ...current, result },
+ },
+ };
+ }
+
+ return state;
+}
+
+export function chatDisplayBlocks(state: ChatDisplayState): ContentBlock[] {
+ const commentary = Object.values(state.commentarySegments).map(segment => ({
+ kind: 'text' as const,
+ id: `commentary-${segment.id}`,
+ text: segment.text,
+ }));
+ const thinking = Object.values(state.thinkingSegments).map(segment => ({
+ kind: 'text' as const,
+ id: `thinking-${segment.id}`,
+ text: segment.text,
+ }));
+ const tools = Object.values(state.toolExecutions).map(tool => ({
+ kind: 'tool' as const,
+ id: tool.id,
+ toolName: tool.toolName,
+ text: tool.text,
+ result: tool.result,
+ }));
+ const pending = Object.values(state.pendingSegments).map(segment => ({
+ kind: 'text' as const,
+ id: `pending-${segment.id}`,
+ text: segment.text,
+ }));
+ const finalAnswer = state.finalAnswer
+ ? [{ kind: 'text' as const, id: `final-${state.finalAnswer.id}`, text: state.finalAnswer.text }]
+ : [];
+ return [...commentary, ...thinking, ...tools, ...pending, ...finalAnswer];
+}
+
const NEAR_BOTTOM_PX = 96;
const S: Record = {
@@ -156,12 +352,11 @@ function eventsToMessages(events: SessionEvent[]): Message[] {
role: 'user',
blocks: [{ kind: 'text', id: evt.id, text: payloadText(evt.payload) }],
});
- } else if (evt.type === 'agent.turn_stub' || evt.type === 'agent.message') {
- ensureOpen(evt.id).blocks.push({
- kind: 'text',
- id: evt.id,
- text: payloadText(evt.payload) || '[agent response]',
- });
+ } else if (evt.type === 'agent.message') {
+ const text = payloadText(evt.payload);
+ if (text) {
+ ensureOpen(evt.id).blocks.push({ kind: 'text', id: evt.id, text });
+ }
} else if (evt.type === 'agent.tool_use') {
ensureOpen(evt.id).blocks.push({
kind: 'tool',
@@ -320,63 +515,88 @@ export default function ChatPanel({
if (!id) return prev;
return prev.map(m => (m.id === id ? { ...m, pending: false, closed: true } : m));
};
- const append = (prev: Message[], seedId: string, block: ContentBlock): Message[] => {
- const cur = openMsgIdRef.current;
- if (cur) {
- const existing = prev.find(m => m.id === cur);
- if (existing && !existing.closed) {
- // Avoid duplicate blocks for the same event id (preview vs persisted).
- if (existing.blocks.some(
- b => b.kind === block.kind && (b.id === block.id || b.id === seedId),
- )) return prev;
- return prev.map(m =>
- m.id === cur ? { ...m, blocks: [...m.blocks, block], pending: true } : m);
+ const updateDisplay = (prev: Message[]): Message[] => {
+ const currentId = openMsgIdRef.current;
+ const current = currentId
+ ? prev.find(message => message.id === currentId && !message.closed)
+ : undefined;
+ const displayState = applyChatFrame(
+ current?.displayState ?? createChatDisplayState(),
+ evt,
+ );
+ const displayBlocks = chatDisplayBlocks(displayState);
+ const attributes = { ...(evt.payload ?? {}), ...(evt.attributes ?? {}) };
+ const settled = evt.type === 'agent.message'
+ || (evt.type === 'event_update'
+ && attributes.authoritative === true
+ && attributes.hasOutput === false);
+
+ if (current) {
+ if (displayBlocks.length === 0 && current.blocks.length === 0) {
+ openMsgIdRef.current = null;
+ return prev.filter(message => message.id !== current.id);
}
+ return prev.map(message => message.id === current.id
+ ? {
+ ...message,
+ displayState,
+ pending: settled ? false : evt.type.startsWith('event_') ? true : message.pending,
+ }
+ : message);
}
- openMsgIdRef.current = `${seedId}-turn`;
- return [...prev, { id: `${seedId}-turn`, role: 'assistant', blocks: [block], pending: true }];
+
+ if (displayBlocks.length === 0) return prev;
+ const seedId = String(evt.payload?.event_id ?? evt.id ?? nextId());
+ const messageId = `${seedId}-turn`;
+ openMsgIdRef.current = messageId;
+ return [...prev, {
+ id: messageId,
+ role: 'assistant',
+ blocks: [],
+ displayState,
+ pending: !settled,
+ }];
};
- if (evt.type === 'event_start') {
- const targetType = String(evt.payload?.type ?? '');
- const eventId = String(evt.payload?.event_id ?? '');
- if (!eventId || targetType !== 'agent.message') return;
- // Reserve the turn bubble so deltas stream into it.
- setMessages(prev => append(prev, eventId, { kind: 'text', id: eventId, text: '' }));
+ if (evt.type === 'event_start'
+ || evt.type === 'event_delta'
+ || evt.type === 'event_update'
+ || evt.type === 'agent.message'
+ || evt.type === 'agent.tool_use') {
+ setMessages(updateDisplay);
return;
}
- if (evt.type === 'event_delta') {
- const targetType = String(evt.payload?.type ?? '');
- const eventId = String(evt.payload?.event_id ?? '');
- const delta = evt.payload?.delta != null ? String(evt.payload.delta) : '';
- if (!eventId || !delta) return;
- if (targetType === 'agent.message') {
- setMessages(prev => {
- const cur = openMsgIdRef.current;
- if (cur) {
- const existing = prev.find(m => m.id === cur);
- if (existing && !existing.closed) {
- return prev.map(m => {
- if (m.id !== cur) return m;
- const idx = m.blocks.findIndex(b => b.kind === 'text' && b.id === eventId);
- if (idx >= 0) {
- return {
- ...m,
- blocks: m.blocks.map((b, i) =>
- i === idx ? { ...b, text: (b.text ?? '') + delta } : b),
- pending: true,
- };
- }
- return { ...m, blocks: [...m.blocks, { kind: 'text', id: eventId, text: delta }], pending: true };
- });
- }
- }
- return append(prev, eventId, { kind: 'text', id: eventId, text: delta });
+ if (evt.type === 'agent.tool_result') {
+ const toolId = String(
+ evt.payload?.tool_use_id ?? evt.payload?.toolCallId ?? evt.payload?.id ?? '',
+ );
+ if (!toolId) return;
+ setMessages(prev => {
+ const current = openMsgIdRef.current
+ ? prev.find(message => message.id === openMsgIdRef.current && !message.closed)
+ : undefined;
+ if (current?.displayState?.toolExecutions[toolId]) {
+ return updateDisplay(prev);
+ }
+ const result = evt.payload?.output != null
+ ? String(evt.payload.output)
+ : payloadText(evt.payload);
+ let updated = false;
+ const next = prev.map(message => {
+ const index = message.blocks.findIndex(
+ block => block.kind === 'tool' && block.id === toolId,
+ );
+ if (index < 0) return message;
+ updated = true;
+ return {
+ ...message,
+ blocks: message.blocks.map((block, i) =>
+ i === index ? { ...block, result } : block),
+ };
});
- } else if (targetType === 'agent.tool_use') {
- setMessages(prev => append(prev, eventId, { kind: 'tool', id: eventId, toolName: 'tool', text: delta }));
- }
+ return updated ? next : prev;
+ });
return;
}
@@ -399,84 +619,6 @@ export default function ChatPanel({
return;
}
- if (evt.type === 'agent.message' || evt.type === 'agent.turn_stub') {
- const text = payloadText(evt.payload) || '[agent response]';
- setMessages(prev => {
- // The final persisted event carries the full text: replace the streamed
- // preview block instead of appending a duplicate.
- const cur = openMsgIdRef.current;
- if (cur) {
- const existing = prev.find(m => m.id === cur);
- if (existing && !existing.closed) {
- const idx = existing.blocks.findIndex(b => b.kind === 'text' && b.id === evt.id);
- if (idx >= 0) {
- return prev.map(m =>
- m.id === cur
- ? { ...m, blocks: m.blocks.map((b, i) => (i === idx ? { ...b, text } : b)) }
- : m);
- }
- }
- }
- return append(prev, evt.id, { kind: 'text', id: evt.id, text });
- });
- return;
- }
-
- if (evt.type === 'agent.tool_use') {
- const toolId = String(
- evt.payload?.id ?? evt.payload?.toolCallId ?? evt.payload?.toolUseId ?? evt.id,
- );
- const toolName = String(evt.payload?.name ?? evt.payload?.toolName ?? 'tool');
- const input = evt.payload?.input != null ? JSON.stringify(evt.payload.input) : undefined;
- setMessages(prev => {
- // Adopt the preview block (id = the event id) and finalize its id to the
- // tool-call id so tool_result can match it later.
- const cur = openMsgIdRef.current;
- if (cur) {
- const existing = prev.find(m => m.id === cur);
- if (existing && !existing.closed) {
- const idx = existing.blocks.findIndex(
- b => b.kind === 'tool' && (b.id === toolId || b.id === evt.id),
- );
- if (idx >= 0) {
- return prev.map(m =>
- m.id === cur
- ? {
- ...m,
- blocks: m.blocks.map((b, i) =>
- i === idx ? { ...b, id: toolId, toolName, text: b.text ?? input } : b),
- pending: false,
- }
- : m);
- }
- }
- }
- return append(prev, evt.id, { kind: 'tool', id: toolId, toolName, text: input });
- });
- return;
- }
-
- if (evt.type === 'agent.tool_result') {
- const toolUseId = String(
- evt.payload?.tool_use_id ?? evt.payload?.toolCallId ?? evt.payload?.id ?? '',
- );
- const output = evt.payload?.output != null
- ? String(evt.payload.output)
- : payloadText(evt.payload);
- if (!toolUseId) return;
- setMessages(prev => {
- let updated = false;
- const next = prev.map(m => {
- const idx = m.blocks.findIndex(b => b.kind === 'tool' && b.id === toolUseId);
- if (idx < 0) return m;
- updated = true;
- return { ...m, blocks: m.blocks.map((b, i) => (i === idx ? { ...b, result: output } : b)) };
- });
- return updated ? next : prev;
- });
- return;
- }
-
if (evt.type.startsWith('session.status')) {
// Keep the turn bubble open across status events (the backend may emit
// status_idle between model iterations of one user question); only
@@ -800,7 +942,7 @@ export default function ChatPanel({
From 6da2b3377924f67977ce7113f2f83128ed531637 Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Thu, 3 Sep 2026 16:38:50 +0800
Subject: [PATCH 14/20] =?UTF-8?q?fix(frontend):=20=E4=BF=9D=E7=95=99?=
=?UTF-8?q?=E6=B5=81=E5=BC=8F=E4=BA=8B=E4=BB=B6=E9=A1=BA=E5=BA=8F=E5=B9=B6?=
=?UTF-8?q?=E7=BB=AD=E6=8E=A5=E6=81=A2=E5=A4=8D=E4=BC=9A=E8=AF=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/components/ChatPanel.test.tsx | 87 +++-
.../frontend/src/components/ChatPanel.tsx | 451 ++++++++++++------
.../frontend/src/components/MessageBlock.tsx | 75 ++-
3 files changed, 467 insertions(+), 146 deletions(-)
diff --git a/agentscope-service/frontend/src/components/ChatPanel.test.tsx b/agentscope-service/frontend/src/components/ChatPanel.test.tsx
index c265ff18fc..7c766dc806 100644
--- a/agentscope-service/frontend/src/components/ChatPanel.test.tsx
+++ b/agentscope-service/frontend/src/components/ChatPanel.test.tsx
@@ -15,12 +15,16 @@
*/
import { describe, expect, it } from 'vitest';
+import { renderToStaticMarkup } from 'react-dom/server';
import type { SessionEvent } from '../api/managedSessions';
import {
+ applyAssistantFrameToHistory,
applyChatFrame,
chatDisplayBlocks,
createChatDisplayState,
+ restoreChatHistory,
} from './ChatPanel';
+import MessageBlock from './MessageBlock';
function frame(
id: string,
@@ -51,8 +55,7 @@ function updateFrame(eventId: string, attributes: Record): Sess
return frame(
`update-${eventId}`,
'event_update',
- { event_id: eventId, type: 'agent.message' },
- attributes,
+ { event_id: eventId, type: 'agent.message', ...attributes },
);
}
@@ -60,6 +63,14 @@ function persistedAgentMessage(eventId: string, text: string): SessionEvent {
return frame(eventId, 'agent.message', { text });
}
+function persistedToolUse(eventId: string, toolId: string, toolName: string): SessionEvent {
+ return frame(eventId, 'agent.tool_use', {
+ id: toolId,
+ name: toolName,
+ input: { city: 'Hangzhou' },
+ });
+}
+
describe('chat display state', () => {
it('moves intermediate preview to commentary and confirms only authoritative result', () => {
let state = createChatDisplayState();
@@ -108,4 +119,76 @@ describe('chat display state', () => {
expect(state.toolExecutions['tool-1'].text).toBe('{"city":"Hangzhou"}');
expect(state.finalAnswer).toBeUndefined();
});
+
+ it('settles a terminal preview without treating it as the final answer', () => {
+ let state = createChatDisplayState();
+ state = applyChatFrame(state, startFrame('preview-terminal'));
+ state = applyChatFrame(state, deltaFrame('preview-terminal', 'candidate'));
+ state = applyChatFrame(state, updateFrame('preview-terminal', {
+ disposition: 'TERMINAL',
+ generateReason: 'MODEL_STOP',
+ }));
+
+ expect(state.pendingSegments['preview-terminal'].pending).toBe(false);
+ expect(state.finalAnswer).toBeUndefined();
+ expect(chatDisplayBlocks(state)).toEqual([
+ expect.objectContaining({
+ id: 'preview-terminal',
+ presentation: 'preview',
+ text: 'candidate',
+ }),
+ ]);
+ });
+
+ it('continues a restored tool turn in the same assistant message', () => {
+ const history = restoreChatHistory([
+ frame('user-1', 'user.message', { text: 'weather?' }),
+ persistedToolUse('tool-event-1', 'tool-call-1', 'weather'),
+ ]);
+
+ expect(history.openMessageId).toBe('tool-event-1-turn');
+ expect(history.messages).toHaveLength(2);
+
+ const resumed = applyAssistantFrameToHistory(
+ history,
+ deltaFrame('answer-preview', 'It is sunny.'),
+ );
+
+ expect(resumed.messages).toHaveLength(2);
+ expect(resumed.messages[1].id).toBe('tool-event-1-turn');
+ expect(resumed.messages[1].displayState?.toolExecutions['tool-call-1'].toolName)
+ .toBe('weather');
+ expect(resumed.messages[1].displayState?.pendingSegments['answer-preview'].text)
+ .toBe('It is sunny.');
+ });
+
+ it('preserves wire order and presentation across repeated reply segments and tools', () => {
+ let state = createChatDisplayState();
+ state = applyChatFrame(state, deltaFrame('reply-1', 'checking'));
+ state = applyChatFrame(state, updateFrame('reply-1', { disposition: 'INTERMEDIATE' }));
+ state = applyChatFrame(state, startFrame('tool-preview-1', 'agent.tool_use'));
+ state = applyChatFrame(state, persistedToolUse('tool-preview-1', 'tool-call-1', 'lookup'));
+ state = applyChatFrame(state, deltaFrame('reply-1', 'writing'));
+ state = applyChatFrame(state, updateFrame('reply-1', { disposition: 'INTERMEDIATE' }));
+ state = applyChatFrame(state, deltaFrame('reply-1', 'answer'));
+ state = applyChatFrame(state, persistedAgentMessage('reply-1', 'final answer'));
+
+ const blocks = chatDisplayBlocks(state);
+ expect(blocks.map(block => [block.presentation ?? 'tool', block.text])).toEqual([
+ ['commentary', 'checking'],
+ ['tool', ''],
+ ['commentary', 'writing'],
+ ['final', 'final answer'],
+ ]);
+ expect(new Set(blocks.map(block => block.id)).size).toBe(blocks.length);
+
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('Commentary');
+ expect(html).toContain('Final answer');
+ expect(html.indexOf('checking')).toBeLessThan(html.indexOf('lookup'));
+ expect(html.indexOf('lookup')).toBeLessThan(html.indexOf('writing'));
+ expect(html.indexOf('writing')).toBeLessThan(html.indexOf('final answer'));
+ });
});
diff --git a/agentscope-service/frontend/src/components/ChatPanel.tsx b/agentscope-service/frontend/src/components/ChatPanel.tsx
index 8add7e77c3..4162efe173 100644
--- a/agentscope-service/frontend/src/components/ChatPanel.tsx
+++ b/agentscope-service/frontend/src/components/ChatPanel.tsx
@@ -31,7 +31,7 @@ import MessageBlock, { ContentBlock } from './MessageBlock';
type Role = 'user' | 'assistant' | 'system' | 'error';
-interface Message {
+export interface Message {
id: string;
role: Role;
blocks: ContentBlock[];
@@ -49,7 +49,9 @@ interface PendingConfirmation {
export interface ChatTextSegment {
id: string;
+ sourceId: string;
text: string;
+ pending: boolean;
}
export interface ChatToolExecution {
@@ -59,12 +61,24 @@ export interface ChatToolExecution {
result?: string;
}
+export type ChatTextPresentation = 'commentary' | 'thinking' | 'pending' | 'preview' | 'final';
+
+export interface ChatDisplayEntry {
+ key: string;
+ kind: 'text' | 'tool';
+ refId: string;
+ presentation?: ChatTextPresentation;
+}
+
export interface ChatDisplayState {
pendingSegments: Record;
commentarySegments: Record;
thinkingSegments: Record;
toolExecutions: Record;
finalAnswer?: ChatTextSegment;
+ displayOrder: ChatDisplayEntry[];
+ activePendingSegmentIds: Record;
+ nextSequence: number;
}
export function createChatDisplayState(): ChatDisplayState {
@@ -73,20 +87,66 @@ export function createChatDisplayState(): ChatDisplayState {
commentarySegments: {},
thinkingSegments: {},
toolExecutions: {},
+ displayOrder: [],
+ activePendingSegmentIds: {},
+ nextSequence: 1,
};
}
-function appendText(
- segments: Record,
- id: string,
- delta: string,
-): Record {
+function cloneDisplayState(state: ChatDisplayState): ChatDisplayState {
return {
- ...segments,
- [id]: { id, text: `${segments[id]?.text ?? ''}${delta}` },
+ ...state,
+ pendingSegments: { ...state.pendingSegments },
+ commentarySegments: { ...state.commentarySegments },
+ thinkingSegments: { ...state.thinkingSegments },
+ toolExecutions: { ...state.toolExecutions },
+ displayOrder: [...state.displayOrder],
+ activePendingSegmentIds: { ...state.activePendingSegmentIds },
};
}
+function uniqueDisplayKey(state: ChatDisplayState, preferred: string): string {
+ if (!state.displayOrder.some(entry => entry.key === preferred)) return preferred;
+ let key: string;
+ do {
+ key = `${preferred}:${state.nextSequence++}`;
+ } while (state.displayOrder.some(entry => entry.key === key));
+ return key;
+}
+
+function ensurePendingSegment(state: ChatDisplayState, sourceId: string): string {
+ const activeId = state.activePendingSegmentIds[sourceId];
+ if (activeId && state.pendingSegments[activeId]) return activeId;
+ const segmentId = uniqueDisplayKey(state, sourceId);
+ state.pendingSegments[segmentId] = {
+ id: segmentId,
+ sourceId,
+ text: '',
+ pending: true,
+ };
+ state.activePendingSegmentIds[sourceId] = segmentId;
+ state.displayOrder.push({
+ key: segmentId,
+ kind: 'text',
+ refId: segmentId,
+ presentation: 'pending',
+ });
+ return segmentId;
+}
+
+function ensureToolExecution(state: ChatDisplayState, eventId: string): ChatToolExecution {
+ const existing = state.toolExecutions[eventId];
+ if (existing) return existing;
+ const execution = { id: eventId, toolName: 'tool', text: '' };
+ state.toolExecutions[eventId] = execution;
+ state.displayOrder.push({
+ key: uniqueDisplayKey(state, eventId),
+ kind: 'tool',
+ refId: eventId,
+ });
+ return execution;
+}
+
/** Applies one persisted event or stream-only preview frame to the current assistant turn. */
export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): ChatDisplayState {
const payload = event.payload ?? {};
@@ -96,23 +156,14 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
if (event.type === 'event_start') {
if (!eventId) return state;
if (targetType === 'agent.message') {
- return {
- ...state,
- pendingSegments: {
- ...state.pendingSegments,
- [eventId]: state.pendingSegments[eventId] ?? { id: eventId, text: '' },
- },
- };
+ const next = cloneDisplayState(state);
+ ensurePendingSegment(next, eventId);
+ return next;
}
if (targetType === 'agent.tool_use') {
- return {
- ...state,
- toolExecutions: {
- ...state.toolExecutions,
- [eventId]: state.toolExecutions[eventId]
- ?? { id: eventId, toolName: 'tool', text: '' },
- },
- };
+ const next = cloneDisplayState(state);
+ ensureToolExecution(next, eventId);
+ return next;
}
return state;
}
@@ -121,25 +172,44 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
const delta = payload.delta != null ? String(payload.delta) : '';
if (!eventId || !delta) return state;
if (targetType === 'agent.message') {
- return { ...state, pendingSegments: appendText(state.pendingSegments, eventId, delta) };
+ const next = cloneDisplayState(state);
+ const segmentId = ensurePendingSegment(next, eventId);
+ next.pendingSegments[segmentId] = {
+ ...next.pendingSegments[segmentId],
+ text: `${next.pendingSegments[segmentId].text}${delta}`,
+ pending: true,
+ };
+ return next;
}
if (targetType === 'agent.thinking') {
- return { ...state, thinkingSegments: appendText(state.thinkingSegments, eventId, delta) };
+ const next = cloneDisplayState(state);
+ const existing = next.thinkingSegments[eventId];
+ if (!existing) {
+ next.thinkingSegments[eventId] = {
+ id: eventId,
+ sourceId: eventId,
+ text: delta,
+ pending: true,
+ };
+ next.displayOrder.push({
+ key: uniqueDisplayKey(next, eventId),
+ kind: 'text',
+ refId: eventId,
+ presentation: 'thinking',
+ });
+ } else {
+ next.thinkingSegments[eventId] = { ...existing, text: `${existing.text}${delta}` };
+ }
+ return next;
}
if (targetType === 'agent.tool_use') {
- const current = state.toolExecutions[eventId];
- return {
- ...state,
- toolExecutions: {
- ...state.toolExecutions,
- [eventId]: {
- id: eventId,
- toolName: current?.toolName ?? 'tool',
- text: `${current?.text ?? ''}${delta}`,
- result: current?.result,
- },
- },
+ const next = cloneDisplayState(state);
+ const current = ensureToolExecution(next, eventId);
+ next.toolExecutions[eventId] = {
+ ...current,
+ text: `${current.text ?? ''}${delta}`,
};
+ return next;
}
return state;
}
@@ -147,53 +217,94 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
if (event.type === 'event_update' && targetType === 'agent.message' && eventId) {
const attributes = { ...payload, ...(event.attributes ?? {}) };
if (attributes.disposition === 'INTERMEDIATE') {
- const pending = state.pendingSegments[eventId];
+ const segmentId = state.activePendingSegmentIds[eventId];
+ const pending = segmentId ? state.pendingSegments[segmentId] : undefined;
if (!pending) return state;
- const pendingSegments = { ...state.pendingSegments };
- delete pendingSegments[eventId];
- return {
- ...state,
- pendingSegments,
- commentarySegments: appendText(
- state.commentarySegments,
- eventId,
- pending.text,
- ),
- };
+ const next = cloneDisplayState(state);
+ delete next.pendingSegments[segmentId];
+ delete next.activePendingSegmentIds[eventId];
+ next.commentarySegments[segmentId] = { ...pending, pending: false };
+ next.displayOrder = next.displayOrder.map(entry => entry.refId === segmentId
+ ? { ...entry, presentation: 'commentary' }
+ : entry);
+ return next;
+ }
+ if (attributes.disposition === 'TERMINAL') {
+ const segmentId = state.activePendingSegmentIds[eventId];
+ const pending = segmentId ? state.pendingSegments[segmentId] : undefined;
+ if (!pending) return state;
+ const next = cloneDisplayState(state);
+ next.pendingSegments[segmentId] = { ...pending, pending: false };
+ delete next.activePendingSegmentIds[eventId];
+ next.displayOrder = next.displayOrder.map(entry => entry.refId === segmentId
+ ? { ...entry, presentation: 'preview' }
+ : entry);
+ return next;
}
if (attributes.authoritative === true && attributes.hasOutput === false) {
- if (!state.pendingSegments[eventId]) return state;
- const pendingSegments = { ...state.pendingSegments };
- delete pendingSegments[eventId];
- return { ...state, pendingSegments };
+ const removedIds = Object.values(state.pendingSegments)
+ .filter(segment => segment.sourceId === eventId)
+ .map(segment => segment.id);
+ if (removedIds.length === 0) return state;
+ const next = cloneDisplayState(state);
+ for (const id of removedIds) delete next.pendingSegments[id];
+ delete next.activePendingSegmentIds[eventId];
+ next.displayOrder = next.displayOrder.filter(entry => !removedIds.includes(entry.refId));
+ return next;
}
return state;
}
if (event.type === 'agent.message') {
const text = payloadText(event.payload);
- const pendingSegments = { ...state.pendingSegments };
- delete pendingSegments[event.id];
- return {
- ...state,
- pendingSegments,
- finalAnswer: text ? { id: event.id, text } : undefined,
- };
+ const next = cloneDisplayState(state);
+ const removedEntries = next.displayOrder.filter(entry => {
+ const segment = next.pendingSegments[entry.refId];
+ return segment?.sourceId === event.id;
+ });
+ const insertionIndex = removedEntries.length > 0
+ ? next.displayOrder.findIndex(entry => entry.key === removedEntries[0].key)
+ : next.displayOrder.length;
+ const removedIds = Object.values(next.pendingSegments)
+ .filter(segment => segment.sourceId === event.id)
+ .map(segment => segment.id);
+ for (const id of removedIds) delete next.pendingSegments[id];
+ delete next.activePendingSegmentIds[event.id];
+ next.displayOrder = next.displayOrder.filter(entry =>
+ !removedIds.includes(entry.refId) && entry.presentation !== 'final');
+ next.finalAnswer = text
+ ? { id: event.id, sourceId: event.id, text, pending: false }
+ : undefined;
+ if (next.finalAnswer) {
+ const key = removedEntries[0]?.key ?? uniqueDisplayKey(next, `final-${event.id}`);
+ next.displayOrder.splice(insertionIndex, 0, {
+ key,
+ kind: 'text',
+ refId: event.id,
+ presentation: 'final',
+ });
+ }
+ return next;
}
if (event.type === 'agent.tool_use') {
const toolId = String(payload.id ?? payload.toolCallId ?? payload.toolUseId ?? event.id);
if (!toolId) return state;
- const preview = state.toolExecutions[event.id];
- const toolExecutions = { ...state.toolExecutions };
- if (event.id !== toolId) delete toolExecutions[event.id];
- toolExecutions[toolId] = {
+ const next = cloneDisplayState(state);
+ const preview = next.toolExecutions[event.id];
+ if (!preview) ensureToolExecution(next, event.id);
+ const source = preview ?? next.toolExecutions[event.id];
+ if (event.id !== toolId) delete next.toolExecutions[event.id];
+ next.toolExecutions[toolId] = {
id: toolId,
- toolName: String(payload.name ?? payload.toolName ?? preview?.toolName ?? 'tool'),
- text: preview?.text ?? (payload.input != null ? JSON.stringify(payload.input) : undefined),
- result: preview?.result,
+ toolName: String(payload.name ?? payload.toolName ?? source?.toolName ?? 'tool'),
+ text: source?.text ?? (payload.input != null ? JSON.stringify(payload.input) : undefined),
+ result: source?.result,
};
- return { ...state, toolExecutions };
+ next.displayOrder = next.displayOrder.map(entry => entry.kind === 'tool' && entry.refId === event.id
+ ? { ...entry, refId: toolId }
+ : entry);
+ return next;
}
if (event.type === 'agent.tool_result') {
@@ -214,32 +325,33 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
}
export function chatDisplayBlocks(state: ChatDisplayState): ContentBlock[] {
- const commentary = Object.values(state.commentarySegments).map(segment => ({
- kind: 'text' as const,
- id: `commentary-${segment.id}`,
- text: segment.text,
- }));
- const thinking = Object.values(state.thinkingSegments).map(segment => ({
- kind: 'text' as const,
- id: `thinking-${segment.id}`,
- text: segment.text,
- }));
- const tools = Object.values(state.toolExecutions).map(tool => ({
- kind: 'tool' as const,
- id: tool.id,
- toolName: tool.toolName,
- text: tool.text,
- result: tool.result,
- }));
- const pending = Object.values(state.pendingSegments).map(segment => ({
- kind: 'text' as const,
- id: `pending-${segment.id}`,
- text: segment.text,
- }));
- const finalAnswer = state.finalAnswer
- ? [{ kind: 'text' as const, id: `final-${state.finalAnswer.id}`, text: state.finalAnswer.text }]
- : [];
- return [...commentary, ...thinking, ...tools, ...pending, ...finalAnswer];
+ return state.displayOrder.flatMap(entry => {
+ if (entry.kind === 'tool') {
+ const tool = state.toolExecutions[entry.refId];
+ return tool ? [{
+ kind: 'tool' as const,
+ id: tool.id,
+ renderKey: entry.key,
+ toolName: tool.toolName,
+ text: tool.text,
+ result: tool.result,
+ }] : [];
+ }
+ const segment = entry.presentation === 'commentary'
+ ? state.commentarySegments[entry.refId]
+ : entry.presentation === 'thinking'
+ ? state.thinkingSegments[entry.refId]
+ : entry.presentation === 'final'
+ ? state.finalAnswer
+ : state.pendingSegments[entry.refId];
+ return segment ? [{
+ kind: 'text' as const,
+ id: entry.key,
+ sourceId: segment.sourceId,
+ text: segment.text,
+ presentation: entry.presentation,
+ }] : [];
+ });
}
const NEAR_BOTTOM_PX = 96;
@@ -323,7 +435,12 @@ function errorText(evt: SessionEvent): string {
return `${label} ${message || 'Session turn failed'}`.trim();
}
-function eventsToMessages(events: SessionEvent[]): Message[] {
+export interface ChatHistoryState {
+ messages: Message[];
+ openMessageId: string | null;
+}
+
+export function restoreChatHistory(events: SessionEvent[]): ChatHistoryState {
const out: Message[] = [];
// Index of the current assistant turn bubble; content appends into it until
// a status/error event closes the turn.
@@ -355,7 +472,12 @@ function eventsToMessages(events: SessionEvent[]): Message[] {
} else if (evt.type === 'agent.message') {
const text = payloadText(evt.payload);
if (text) {
- ensureOpen(evt.id).blocks.push({ kind: 'text', id: evt.id, text });
+ ensureOpen(evt.id).blocks.push({
+ kind: 'text',
+ id: evt.id,
+ text,
+ presentation: 'final',
+ });
}
} else if (evt.type === 'agent.tool_use') {
ensureOpen(evt.id).blocks.push({
@@ -396,7 +518,96 @@ function eventsToMessages(events: SessionEvent[]): Message[] {
}
}
}
- return out;
+ const openMessage = open >= 0 ? out[open] : undefined;
+ if (openMessage?.role === 'assistant') {
+ const displayState = createChatDisplayState();
+ for (const block of openMessage.blocks) {
+ if (block.kind === 'tool') {
+ displayState.toolExecutions[block.id] = {
+ id: block.id,
+ toolName: block.toolName ?? 'tool',
+ text: block.text,
+ result: block.result,
+ };
+ displayState.displayOrder.push({
+ key: uniqueDisplayKey(displayState, block.renderKey ?? block.id),
+ kind: 'tool',
+ refId: block.id,
+ });
+ } else if (block.text) {
+ displayState.finalAnswer = {
+ id: block.id,
+ sourceId: block.sourceId ?? block.id,
+ text: block.text,
+ pending: false,
+ };
+ displayState.displayOrder.push({
+ key: uniqueDisplayKey(displayState, block.id),
+ kind: 'text',
+ refId: block.id,
+ presentation: 'final',
+ });
+ }
+ }
+ openMessage.blocks = [];
+ openMessage.displayState = displayState;
+ }
+ return {
+ messages: out,
+ openMessageId: openMessage?.role === 'assistant' ? openMessage.id : null,
+ };
+}
+
+export function applyAssistantFrameToHistory(
+ history: ChatHistoryState,
+ event: SessionEvent,
+): ChatHistoryState {
+ const current = history.openMessageId
+ ? history.messages.find(message => message.id === history.openMessageId && !message.closed)
+ : undefined;
+ const displayState = applyChatFrame(
+ current?.displayState ?? createChatDisplayState(),
+ event,
+ );
+ const displayBlocks = chatDisplayBlocks(displayState);
+ const attributes = { ...(event.payload ?? {}), ...(event.attributes ?? {}) };
+ const settled = event.type === 'agent.message'
+ || (event.type === 'event_update'
+ && (attributes.disposition === 'TERMINAL'
+ || (attributes.authoritative === true && attributes.hasOutput === false)));
+
+ if (current) {
+ if (displayBlocks.length === 0 && current.blocks.length === 0) {
+ return {
+ messages: history.messages.filter(message => message.id !== current.id),
+ openMessageId: null,
+ };
+ }
+ return {
+ messages: history.messages.map(message => message.id === current.id
+ ? {
+ ...message,
+ displayState,
+ pending: settled ? false : event.type.startsWith('event_') ? true : message.pending,
+ }
+ : message),
+ openMessageId: current.id,
+ };
+ }
+
+ if (displayBlocks.length === 0) return history;
+ const seedId = String(event.payload?.event_id ?? event.id ?? nextId());
+ const messageId = `${seedId}-turn`;
+ return {
+ messages: [...history.messages, {
+ id: messageId,
+ role: 'assistant',
+ blocks: [],
+ displayState,
+ pending: !settled,
+ }],
+ openMessageId: messageId,
+ };
}
function extractConfirmation(evt: SessionEvent): PendingConfirmation | null {
@@ -516,46 +727,12 @@ export default function ChatPanel({
return prev.map(m => (m.id === id ? { ...m, pending: false, closed: true } : m));
};
const updateDisplay = (prev: Message[]): Message[] => {
- const currentId = openMsgIdRef.current;
- const current = currentId
- ? prev.find(message => message.id === currentId && !message.closed)
- : undefined;
- const displayState = applyChatFrame(
- current?.displayState ?? createChatDisplayState(),
+ const next = applyAssistantFrameToHistory(
+ { messages: prev, openMessageId: openMsgIdRef.current },
evt,
);
- const displayBlocks = chatDisplayBlocks(displayState);
- const attributes = { ...(evt.payload ?? {}), ...(evt.attributes ?? {}) };
- const settled = evt.type === 'agent.message'
- || (evt.type === 'event_update'
- && attributes.authoritative === true
- && attributes.hasOutput === false);
-
- if (current) {
- if (displayBlocks.length === 0 && current.blocks.length === 0) {
- openMsgIdRef.current = null;
- return prev.filter(message => message.id !== current.id);
- }
- return prev.map(message => message.id === current.id
- ? {
- ...message,
- displayState,
- pending: settled ? false : evt.type.startsWith('event_') ? true : message.pending,
- }
- : message);
- }
-
- if (displayBlocks.length === 0) return prev;
- const seedId = String(evt.payload?.event_id ?? evt.id ?? nextId());
- const messageId = `${seedId}-turn`;
- openMsgIdRef.current = messageId;
- return [...prev, {
- id: messageId,
- role: 'assistant',
- blocks: [],
- displayState,
- pending: !settled,
- }];
+ openMsgIdRef.current = next.openMessageId;
+ return next.messages;
};
if (evt.type === 'event_start'
@@ -673,7 +850,9 @@ export default function ChatPanel({
lastSeqRef.current = e.seq;
}
}
- setMessages(eventsToMessages(events));
+ const history = restoreChatHistory(events);
+ openMsgIdRef.current = history.openMessageId;
+ setMessages(history.messages);
streamHandleRef.current = streamEvents(
sessionId,
evt => { if (!cancelled) handleManagedEvent(evt); },
diff --git a/agentscope-service/frontend/src/components/MessageBlock.tsx b/agentscope-service/frontend/src/components/MessageBlock.tsx
index 2ee8fea57b..0be45df91a 100644
--- a/agentscope-service/frontend/src/components/MessageBlock.tsx
+++ b/agentscope-service/frontend/src/components/MessageBlock.tsx
@@ -22,6 +22,9 @@ import ToolCallBlock from './ToolCallBlock';
export interface ContentBlock {
kind: 'text' | 'tool';
id: string;
+ renderKey?: string;
+ sourceId?: string;
+ presentation?: 'commentary' | 'thinking' | 'pending' | 'preview' | 'final';
/** Text content, or the tool-call input when kind === 'tool'. */
text?: string;
toolName?: string;
@@ -39,6 +42,14 @@ export interface MessageBlockProps {
const PREVIEW_CHARS = 120;
const EXPANDED_BODY_MAX_PX = 280;
+const presentationLabels: Record, string> = {
+ commentary: 'Commentary',
+ thinking: 'Thinking',
+ pending: 'Draft',
+ preview: 'Preview',
+ final: 'Final answer',
+};
+
function normalizePreview(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
@@ -155,8 +166,37 @@ const s: Record = {
pending: {
color: '#94a3b8',
},
+ textBlock: {
+ borderRadius: 8,
+ padding: '8px 10px',
+ },
+ textLabel: {
+ marginBottom: 4,
+ color: '#64748b',
+ fontSize: '0.7rem',
+ fontWeight: 700,
+ letterSpacing: '0.05em',
+ textTransform: 'uppercase',
+ },
};
+function presentationStyle(presentation?: ContentBlock['presentation']): React.CSSProperties {
+ switch (presentation) {
+ case 'commentary':
+ return { background: '#f8fafc', borderLeft: '3px solid #94a3b8', color: '#475569' };
+ case 'thinking':
+ return { background: '#f5f3ff', borderLeft: '3px solid #a78bfa', color: '#5b21b6' };
+ case 'pending':
+ return { background: '#eff6ff', borderLeft: '3px solid #60a5fa' };
+ case 'preview':
+ return { background: '#f8fafc', borderLeft: '3px solid #cbd5e1' };
+ case 'final':
+ return { background: '#ffffff', borderLeft: '3px solid #10b981' };
+ default:
+ return {};
+ }
+}
+
export default function MessageBlock({
role,
blocks,
@@ -164,12 +204,22 @@ export default function MessageBlock({
defaultOpen = false,
}: MessageBlockProps) {
const [open, setOpen] = useState(defaultOpen);
- const text = useMemo(
+ const allText = useMemo(
() => blocks.filter(b => b.kind === 'text').map(b => b.text ?? '').join(''),
[blocks],
);
+ const previewText = useMemo(() => {
+ const primary = blocks.filter(block => block.kind === 'text'
+ && block.presentation !== 'commentary'
+ && block.presentation !== 'thinking');
+ if (primary.length > 0) return primary.map(block => block.text ?? '').join('');
+ const context = [...blocks].reverse().find(block => block.kind === 'text');
+ if (!context) return '';
+ const label = context.presentation ? presentationLabels[context.presentation] : 'Context';
+ return `${label}: ${context.text ?? ''}`;
+ }, [blocks]);
const toolCount = useMemo(() => blocks.filter(b => b.kind === 'tool').length, [blocks]);
- const preview = useMemo(() => messagePreviewLine(text, pending), [text, pending]);
+ const preview = useMemo(() => messagePreviewLine(previewText, pending), [previewText, pending]);
const toolHint = toolCount > 0 ? `${toolCount} tool${toolCount === 1 ? '' : 's'}` : '';
const empty = blocks.length === 0;
@@ -185,7 +235,7 @@ export default function MessageBlock({
{open ? '▾' : '▸'}
{role}
{!open && (
-
+
{preview || (toolHint ? `${toolHint}…` : pending ? 'Generating…' : '(empty)')}
)}
@@ -203,17 +253,26 @@ export default function MessageBlock({
? (
role === 'assistant'
? (
-
-
{b.text}
+
+ {b.presentation && (
+
{presentationLabels[b.presentation]}
+ )}
+
+ {b.text}
+
)
: (
-
{b.text}
+
{b.text}
)
)
: (
… }
{empty && !pending && —
}
- {!empty && pending && text.trim() === '' && toolCount === 0 && (
+ {!empty && pending && allText.trim() === '' && toolCount === 0 && (
…
)}
From 50ed7659eff2b46592735270f611444cfe41623b 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 15/20] =?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
---
.../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 22a30bfad040d90833641fd6fa1c2a95e2fd1426 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 16/20] =?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
---
.../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 5179068c0f..b28417f5db 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;
@@ -31,6 +33,7 @@
import io.agentscope.core.event.RequireExternalExecutionEvent;
import io.agentscope.core.event.TextBlockEndEvent;
import io.agentscope.core.event.TextBlockStartEvent;
+import io.agentscope.core.event.TextOutputDispositionEvent;
import io.agentscope.core.event.ThinkingBlockEndEvent;
import io.agentscope.core.event.ThinkingBlockStartEvent;
import io.agentscope.core.event.ToolCallEndEvent;
@@ -171,7 +174,7 @@ public Mono callAsync(ToolCallParam param) {
}
@Test
- void textOnlyReplyEmitsExpectedEventOrder() {
+ void unwrappedTextOnlyStreamPreservesLegacySequenceWithoutDisposition() {
ChatModelBase model =
new ScriptedModel(List.of(() -> Flux.just(textResponse("hello world"))));
ReActAgent agent =
@@ -185,12 +188,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 177d305681..916224275b 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
@@ -343,10 +343,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;
@@ -605,9 +606,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 80c3595e66bcfc034fa64dc76586d37cda2031c4 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 17/20] =?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、前端和数据面的回归验证与最终修复报告。
---
.../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 ++++++++++++++++++-
.../src/components/ChatPanel.test.tsx | 8 +-
.../frontend/src/components/ChatPanel.tsx | 79 +++++++++++----
.../web/managed/SessionEventMapper.java | 23 ++++-
.../web/managed/SessionEventMapperTest.java | 30 ++++++
14 files changed, 425 insertions(+), 68 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 6f7d80fe34..1cc111e652 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;
@@ -78,6 +81,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<>();
@@ -180,6 +185,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;
@@ -231,6 +237,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())
@@ -445,6 +459,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();
}
@@ -471,6 +508,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 f82924416a..5d85297a1f 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