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 emitVisibleTextThen(Mono terminal) {
+        return Mono.deferContextual(
+                context -> {
+                    AgentEventEmitter emitter =
+                            AgentEventEmitter.fromForwardingContext(context).orElseThrow();
+                    emitter.emit(new ModelCallStartEvent("child-model-reply"));
+                    emitter.emit(
+                            new TextBlockDeltaEvent("child-model-reply", "child-text", "working"));
+                    return terminal;
+                });
+    }
+
+    private static List annotatedEvents(RecordingEmitter emitter) {
+        return AgentEventStreams.withTextOutputDisposition(Flux.fromIterable(emitter.snapshot()))
+                .collectList()
+                .block();
+    }
+
     private static final class NoopTaskRepository implements TaskRepository {
         @Override
         public BackgroundTask getTask(RuntimeContext rc, String sessionId, String taskId) {
diff --git a/agentscope-service/frontend/src/components/ChatPanel.test.tsx b/agentscope-service/frontend/src/components/ChatPanel.test.tsx
index 7c766dc806..731a08fa40 100644
--- a/agentscope-service/frontend/src/components/ChatPanel.test.tsx
+++ b/agentscope-service/frontend/src/components/ChatPanel.test.tsx
@@ -169,17 +169,19 @@ describe('chat display state', () => {
     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'));
+    state = applyChatFrame(state, persistedAgentMessage('authoritative-final', 'final answer'));
 
     const blocks = chatDisplayBlocks(state);
     expect(blocks.map(block => [block.presentation ?? 'tool', block.text])).toEqual([
       ['commentary', 'checking'],
       ['tool', ''],
-      ['commentary', 'writing'],
+      ['commentary', 'writinganswer'],
       ['final', 'final answer'],
     ]);
+    expect(Object.keys(state.pendingSegments)).toHaveLength(0);
+    expect(blocks.some(block => block.presentation === 'pending')).toBe(false);
+    expect(blocks.some(block => block.presentation === 'preview')).toBe(false);
     expect(new Set(blocks.map(block => block.id)).size).toBe(blocks.length);
 
     const html = renderToStaticMarkup(
diff --git a/agentscope-service/frontend/src/components/ChatPanel.tsx b/agentscope-service/frontend/src/components/ChatPanel.tsx
index 4162efe173..ee44b41cae 100644
--- a/agentscope-service/frontend/src/components/ChatPanel.tsx
+++ b/agentscope-service/frontend/src/components/ChatPanel.tsx
@@ -78,6 +78,8 @@ export interface ChatDisplayState {
   finalAnswer?: ChatTextSegment;
   displayOrder: ChatDisplayEntry[];
   activePendingSegmentIds: Record;
+  activeCommentarySegmentIds: Record;
+  knownDispositions: Record;
   nextSequence: number;
 }
 
@@ -89,6 +91,8 @@ export function createChatDisplayState(): ChatDisplayState {
     toolExecutions: {},
     displayOrder: [],
     activePendingSegmentIds: {},
+    activeCommentarySegmentIds: {},
+    knownDispositions: {},
     nextSequence: 1,
   };
 }
@@ -102,6 +106,8 @@ function cloneDisplayState(state: ChatDisplayState): ChatDisplayState {
     toolExecutions: { ...state.toolExecutions },
     displayOrder: [...state.displayOrder],
     activePendingSegmentIds: { ...state.activePendingSegmentIds },
+    activeCommentarySegmentIds: { ...state.activeCommentarySegmentIds },
+    knownDispositions: { ...state.knownDispositions },
   };
 }
 
@@ -134,6 +140,30 @@ function ensurePendingSegment(state: ChatDisplayState, sourceId: string): string
   return segmentId;
 }
 
+function ensureCommentarySegment(state: ChatDisplayState, sourceId: string): string {
+  const activeId = state.activeCommentarySegmentIds[sourceId];
+  if (activeId && state.commentarySegments[activeId]) return activeId;
+  const segmentId = uniqueDisplayKey(state, sourceId);
+  state.commentarySegments[segmentId] = {
+    id: segmentId,
+    sourceId,
+    text: '',
+    pending: false,
+  };
+  state.activeCommentarySegmentIds[sourceId] = segmentId;
+  state.displayOrder.push({
+    key: segmentId,
+    kind: 'text',
+    refId: segmentId,
+    presentation: 'commentary',
+  });
+  return segmentId;
+}
+
+function closeActiveCommentarySegments(state: ChatDisplayState): void {
+  state.activeCommentarySegmentIds = {};
+}
+
 function ensureToolExecution(state: ChatDisplayState, eventId: string): ChatToolExecution {
   const existing = state.toolExecutions[eventId];
   if (existing) return existing;
@@ -157,11 +187,16 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
     if (!eventId) return state;
     if (targetType === 'agent.message') {
       const next = cloneDisplayState(state);
-      ensurePendingSegment(next, eventId);
+      if (next.knownDispositions[eventId] === 'INTERMEDIATE') {
+        ensureCommentarySegment(next, eventId);
+      } else {
+        ensurePendingSegment(next, eventId);
+      }
       return next;
     }
     if (targetType === 'agent.tool_use') {
       const next = cloneDisplayState(state);
+      closeActiveCommentarySegments(next);
       ensureToolExecution(next, eventId);
       return next;
     }
@@ -173,12 +208,20 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
     if (!eventId || !delta) return state;
     if (targetType === 'agent.message') {
       const next = cloneDisplayState(state);
-      const segmentId = ensurePendingSegment(next, eventId);
-      next.pendingSegments[segmentId] = {
-        ...next.pendingSegments[segmentId],
-        text: `${next.pendingSegments[segmentId].text}${delta}`,
-        pending: true,
-      };
+      if (next.knownDispositions[eventId] === 'INTERMEDIATE') {
+        const segmentId = ensureCommentarySegment(next, eventId);
+        next.commentarySegments[segmentId] = {
+          ...next.commentarySegments[segmentId],
+          text: `${next.commentarySegments[segmentId].text}${delta}`,
+        };
+      } else {
+        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') {
@@ -204,6 +247,7 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
     }
     if (targetType === 'agent.tool_use') {
       const next = cloneDisplayState(state);
+      closeActiveCommentarySegments(next);
       const current = ensureToolExecution(next, eventId);
       next.toolExecutions[eventId] = {
         ...current,
@@ -219,11 +263,13 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
     if (attributes.disposition === 'INTERMEDIATE') {
       const segmentId = state.activePendingSegmentIds[eventId];
       const pending = segmentId ? state.pendingSegments[segmentId] : undefined;
-      if (!pending) return state;
       const next = cloneDisplayState(state);
+      next.knownDispositions[eventId] = 'INTERMEDIATE';
+      if (!pending) return next;
       delete next.pendingSegments[segmentId];
       delete next.activePendingSegmentIds[eventId];
       next.commentarySegments[segmentId] = { ...pending, pending: false };
+      next.activeCommentarySegmentIds[eventId] = segmentId;
       next.displayOrder = next.displayOrder.map(entry => entry.refId === segmentId
         ? { ...entry, presentation: 'commentary' }
         : entry);
@@ -232,8 +278,9 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
     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.knownDispositions[eventId] = 'TERMINAL';
+      if (!pending) return next;
       next.pendingSegments[segmentId] = { ...pending, pending: false };
       delete next.activePendingSegmentIds[eventId];
       next.displayOrder = next.displayOrder.map(entry => entry.refId === segmentId
@@ -258,18 +305,15 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
   if (event.type === 'agent.message') {
     const text = payloadText(event.payload);
     const next = cloneDisplayState(state);
-    const removedEntries = next.displayOrder.filter(entry => {
-      const segment = next.pendingSegments[entry.refId];
-      return segment?.sourceId === event.id;
-    });
+    const removedEntries = next.displayOrder.filter(entry => next.pendingSegments[entry.refId]);
     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);
+    const removedIds = Object.keys(next.pendingSegments);
     for (const id of removedIds) delete next.pendingSegments[id];
-    delete next.activePendingSegmentIds[event.id];
+    next.activePendingSegmentIds = {};
+    next.activeCommentarySegmentIds = {};
+    next.knownDispositions = {};
     next.displayOrder = next.displayOrder.filter(entry =>
       !removedIds.includes(entry.refId) && entry.presentation !== 'final');
     next.finalAnswer = text
@@ -291,6 +335,7 @@ export function applyChatFrame(state: ChatDisplayState, event: SessionEvent): Ch
     const toolId = String(payload.id ?? payload.toolCallId ?? payload.toolUseId ?? event.id);
     if (!toolId) return state;
     const next = cloneDisplayState(state);
+    closeActiveCommentarySegments(next);
     const preview = next.toolExecutions[event.id];
     if (!preview) ensureToolExecution(next, event.id);
     const source = preview ?? next.toolExecutions[event.id];
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 7f1c72ebbf..e904862e57 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
@@ -158,7 +158,7 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) {
                             thinking.getDelta()));
         }
         if (event instanceof TextOutputDispositionEvent disposition) {
-            String eventId = previewIds.messageEventIdIfPresent(disposition);
+            String eventId = previewIds.dispositionMessageEventId(disposition);
             if (eventId == null) {
                 return MappingResult.empty();
             }
@@ -411,6 +411,7 @@ private record ToolKey(InvocationKey invocation, String toolCallId) {}
 
         private final Map messageIdsByReply = new LinkedHashMap<>();
         private final Set intermediateReplies = new HashSet<>();
+        private final Map terminalReplies = new LinkedHashMap<>();
         private final Map thinkingIds = new LinkedHashMap<>();
         private final Map topLevelResults = new LinkedHashMap<>();
         private final Map toolUses = new LinkedHashMap<>();
@@ -422,13 +423,25 @@ public String messageEventId(TextBlockDeltaEvent event) {
             return messageIdsByReply.computeIfAbsent(previewKey, ignored -> newEventId());
         }
 
-        public String messageEventIdIfPresent(TextOutputDispositionEvent event) {
-            return messageIdsByReply.get(previewKey(event, event.getReplyId()));
+        public String dispositionMessageEventId(TextOutputDispositionEvent event) {
+            PreviewKey previewKey = previewKey(event, event.getReplyId());
+            String eventId = messageIdsByReply.get(previewKey);
+            if (eventId != null && event.getDisposition() == TextOutputDisposition.TERMINAL) {
+                terminalReplies.put(previewKey.invocation(), previewKey);
+            }
+            return eventId;
         }
 
-        /** Returns and clears the preview id for this invocation and reply. */
+        /** Returns and clears the preview id finalized for this invocation. */
         public String consumeMessageEventId(AgentEndEvent event) {
-            PreviewKey previewKey = previewKey(event, event.getReplyId());
+            InvocationKey invocationKey = invocationKey(event);
+            PreviewKey terminalReply = terminalReplies.remove(invocationKey);
+            PreviewKey previewKey =
+                    terminalReply != null ? terminalReply : previewKey(event, event.getReplyId());
+            return removeMessageEventId(previewKey);
+        }
+
+        private String removeMessageEventId(PreviewKey previewKey) {
             intermediateReplies.remove(previewKey);
             return messageIdsByReply.remove(previewKey);
         }
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 6aad585c65..f8b18dc06d 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
@@ -140,6 +140,36 @@ void agentResultReusesPreviewMessageEventIdAcrossModelCallEnd() {
         assertThat(result.persisted().orElseThrow().eventId()).isEqualTo(previewId);
     }
 
+    @Test
+    void agentEndReusesLastModelPreviewWhenInvocationReplyIdDiffers() {
+        mapper.map(new ModelCallStartEvent("model-reply"), previewIds);
+        String previewId =
+                mapper.map(
+                                new TextBlockDeltaEvent("model-reply", "text-block", "preview"),
+                                previewIds)
+                        .preview()
+                        .orElseThrow()
+                        .eventId();
+        mapper.map(new ModelCallEndEvent("model-reply", null), previewIds);
+
+        Msg msg =
+                Msg.builder()
+                        .role(MsgRole.ASSISTANT)
+                        .textContent("authoritative")
+                        .generateReason(GenerateReason.MODEL_STOP)
+                        .build();
+        mapper.map(new AgentResultEvent(msg), previewIds);
+        mapper.map(
+                new TextOutputDispositionEvent(
+                        "model-reply", TextOutputDisposition.TERMINAL, GenerateReason.MODEL_STOP),
+                previewIds);
+
+        SessionEventMapper.MappingResult result =
+                mapper.map(new AgentEndEvent("invocation-reply"), previewIds);
+
+        assertThat(result.persisted().orElseThrow().eventId()).isEqualTo(previewId);
+    }
+
     /** Each model request opens a fresh preview window; the result reconciles with the last one. */
     @Test
     void multiRoundTurnReusesLastRoundPreviewMessageEventId() {

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

透传子智能体生命周期、思考、文本处置、工具参数结果及任务标识。
修正 acting 中间件事件发布位置,避免事件遗漏或重复。
---
 .../java/io/agentscope/core/ReActAgent.java   |   9 +-
 .../ReActAgentMiddlewareIntegrationTest.java  |  41 ++++
 .../strategy/SubagentEventConverter.java      | 226 +++++++++++++++---
 .../strategy/SubagentEventConverterTest.java  | 166 +++++++++++++
 4 files changed, 403 insertions(+), 39 deletions(-)

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

From 1c6f4a5e7d7cdbf65323138460d9b4f6775342b7 Mon Sep 17 00:00:00 2001
From: dargoner <1793850+dargoner@users.noreply.github.com>
Date: Sun, 6 Sep 2026 19:52:28 +0800
Subject: [PATCH 19/20] =?UTF-8?q?chore(pr):=20=E7=A7=BB=E9=99=A4=E5=86=85?=
 =?UTF-8?q?=E9=83=A8=E5=AE=9E=E6=96=BD=E6=8A=A5=E5=91=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../task-9-report.md                          | 155 ------------------
 1 file changed, 155 deletions(-)
 delete mode 100644 .superpowers/sdd/2026-09-03-stream-events-text-output-disposition/task-9-report.md

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

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

补齐本地子智能体的 AgentResultEvent 转发,避免只收到结束事件而缺少权威结果。
AG-UI 仅按当前运行实际生成的分段消息清理临时文本,避免误删合法历史消息。
Web 预览总线不再重放订阅前事件,并串行化并发发送以避免丢帧。
---
 .../adapter/strategy/AguiStreamContext.java   | 11 +--
 .../agui/adapter/AguiAgentAdapterV2Test.java  | 14 ++--
 .../harness/agent/tool/AgentSpawnTool.java    | 12 ++-
 .../HarnessAgentSubagentStreamEventsTest.java | 12 +++
 .../web/managed/SessionEventPreviewBus.java   | 15 +---
 .../web/managed/SessionEventMapperTest.java   | 81 +++++++++++++++++--
 6 files changed, 112 insertions(+), 33 deletions(-)

diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
index 1cc111e652..015fe5081f 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AguiStreamContext.java
@@ -44,7 +44,6 @@
 import java.util.Set;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
-import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -61,8 +60,6 @@ public class AguiStreamContext {
                     GenerateReason.MODEL_STOP,
                     GenerateReason.STRUCTURED_OUTPUT,
                     GenerateReason.MAX_ITERATIONS);
-    private static final Pattern TEXT_SEGMENT_ID = Pattern.compile("^.+:text:\\d+$");
-
     private final String threadId;
     private final String runId;
     private final AguiAdapterConfig config;
@@ -259,7 +256,7 @@ public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) {
                 authoritativeMessages != null && !authoritativeMessages.isEmpty();
         if (hasAuthoritativeMessages) {
             for (Msg message : authoritativeMessages) {
-                if (message != null && !isTextSegmentId(message.getId())) {
+                if (message != null && !isGeneratedTextSegmentId(message.getId())) {
                     messagesById.put(message.getId(), messageConverter.toAguiMessage(message));
                 }
             }
@@ -267,7 +264,7 @@ public void emitFinalMessagesSnapshot(AgentEndEvent endEvent) {
         if (runInput != null) {
             for (AguiMessage message : runInput.getMessages()) {
                 if (message != null
-                        && !isTextSegmentId(message.getId())
+                        && !isGeneratedTextSegmentId(message.getId())
                         && (!hasAuthoritativeMessages
                                 || messagesById.containsKey(message.getId()))) {
                     messagesById.put(message.getId(), message);
@@ -482,8 +479,8 @@ private void emitTextOutputDisposition(String replyId, TextOutputDispositionStat
                         Collections.unmodifiableMap(value)));
     }
 
-    private static boolean isTextSegmentId(String messageId) {
-        return messageId != null && TEXT_SEGMENT_ID.matcher(messageId).matches();
+    private boolean isGeneratedTextSegmentId(String messageId) {
+        return messageId != null && startedTextMessages.contains(messageId);
     }
 
     private static String normalizeToolCallName(String toolCallName) {
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
index 5d85297a1f..c2e816fb8c 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java
@@ -703,21 +703,21 @@ void testFinalSnapshotFallsBackToOriginalInputWithoutAgentState() {
         }
 
         @Test
-        void testFinalSnapshotExcludesOnlyReservedTextSegmentIds() {
+        void testFinalSnapshotExcludesOnlySegmentsCreatedByCurrentRun() {
             Msg user = Msg.builder().id("session-user").role(MsgRole.USER).textContent("q").build();
-            Msg liveSegment =
+            Msg generatedSegment =
                     AssistantMessage.builder()
-                            .id("reply-preview:text:7")
+                            .id("reply-final:text:0")
                             .content(TextBlock.builder().text("preview").build())
                             .build();
-            Msg ordinaryColonId =
+            Msg legitimatePatternId =
                     AssistantMessage.builder()
-                            .id("reply-preview:text:final")
+                            .id("order:text:0")
                             .content(TextBlock.builder().text("kept").build())
                             .build();
             AgentState state =
                     AgentState.builder()
-                            .context(List.of(user, liveSegment, ordinaryColonId))
+                            .context(List.of(user, generatedSegment, legitimatePatternId))
                             .build();
             RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build();
 
@@ -725,7 +725,7 @@ void testFinalSnapshotExcludesOnlyReservedTextSegmentIds() {
                     runTerminalDisposition(GenerateReason.MODEL_STOP, callerContext);
 
             assertEquals(
-                    List.of("session-user", "reply-preview:text:final", "reply-final"),
+                    List.of("session-user", "order:text:0", "reply-final"),
                     messageIds(snapshot(events)));
         }
 
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java
index e23e8fa1bc..447a596cab 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java
@@ -25,6 +25,7 @@
 import io.agentscope.core.event.AgentEndEvent;
 import io.agentscope.core.event.AgentEvent;
 import io.agentscope.core.event.AgentEventEmitter;
+import io.agentscope.core.event.AgentResultEvent;
 import io.agentscope.core.event.AgentStartEvent;
 import io.agentscope.core.event.SubagentExposedEvent;
 import io.agentscope.core.message.Msg;
@@ -820,7 +821,16 @@ private Mono execLocalSync(
                                 // Emit before success or error reaches the parent, which may
                                 // otherwise complete its event sink before doFinally runs.
                                 .doOnSuccess(
-                                        ignored -> emitEnd.accept(AgentEndEvent.OUTCOME_SUCCESS))
+                                        result -> {
+                                            if (result != null) {
+                                                parentEmitter.emit(
+                                                        tagForwardedEvent(
+                                                                new AgentResultEvent(result),
+                                                                sourcePath,
+                                                                taskId));
+                                            }
+                                            emitEnd.accept(AgentEndEvent.OUTCOME_SUCCESS);
+                                        })
                                 .doOnError(ignored -> emitEnd.accept(AgentEndEvent.OUTCOME_ERROR))
                                 // Preserve best-effort cancellation signaling without emitting a
                                 // duplicate if cancellation races with normal termination.
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java
index f05df63257..83119ec025 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java
@@ -488,6 +488,18 @@ void streamEvents_childAgentStartAndEndEmittedWithSource() throws Exception {
                         .collect(Collectors.toList());
         assertFalse(childEnds.isEmpty(), "expected child AGENT_END with source");
         assertTrue(childEnds.get(0).getSource().contains(childId));
+
+        List childResults =
+                events.stream()
+                        .filter(
+                                e ->
+                                        e.getType() == AgentEventType.AGENT_RESULT
+                                                && e.getSource() != null)
+                        .collect(Collectors.toList());
+        assertEquals(1, childResults.size(), "expected one authoritative child AGENT_RESULT");
+        assertTrue(
+                events.indexOf(childResults.get(0)) < events.indexOf(childEnds.get(0)),
+                "child AGENT_RESULT must be emitted before child AGENT_END");
     }
 
     // -----------------------------------------------------------------
diff --git a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java
index 0d41dc14b3..ba4081db4c 100644
--- a/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java
+++ b/agentscope-service/service-dataplane/src/main/java/io/agentscope/builder/web/managed/SessionEventPreviewBus.java
@@ -18,7 +18,6 @@
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
 import org.springframework.stereotype.Component;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Sinks;
@@ -31,8 +30,7 @@
 @Component
 public class SessionEventPreviewBus {
 
-    private final ConcurrentHashMap> sinks =
-            new ConcurrentHashMap<>();
+    private final Sinks.Many sink = Sinks.many().multicast().directBestEffort();
 
     /** Emits an {@code event_start} frame for a forthcoming persisted type. */
     public void emitStart(String sessionId, String targetType, String eventId) {
@@ -79,18 +77,13 @@ public void emitFrame(
     }
 
     public Flux subscribe(String sessionId) {
-        return sinkFor(sessionId).asFlux();
+        return sink.asFlux().filter(dto -> sessionId.equals(dto.sessionId()));
     }
 
-    private void emit(String sessionId, String type, Map payload) {
+    private synchronized void emit(String sessionId, String type, Map payload) {
         SessionEventDto dto =
                 new SessionEventDto(
                         null, sessionId, -1L, type, payload, null, System.currentTimeMillis());
-        sinkFor(sessionId).tryEmitNext(dto);
-    }
-
-    private Sinks.Many sinkFor(String sessionId) {
-        return sinks.computeIfAbsent(
-                sessionId, ignored -> Sinks.many().multicast().onBackpressureBuffer(512, false));
+        sink.tryEmitNext(dto);
     }
 }
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 f8b18dc06d..da45ce6682 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
@@ -47,6 +47,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -502,6 +503,17 @@ void suspendedAndPausedResultsDoNotCommitOrdinaryAgentMessage() {
     @Test
     void previewBusAndControllerPassUpdateAttributesThroughToSse() throws Exception {
         SessionEventPreviewBus previewBus = new SessionEventPreviewBus();
+        CompletableFuture preview =
+                previewBus.subscribe("session-1").next().toFuture();
+        previewBus.emitUpdate(
+                "session-1",
+                SessionEventTypes.AGENT_MESSAGE,
+                "evt_preview",
+                Map.of("disposition", "INTERMEDIATE", "custom", Map.of("nested", true)));
+        SessionEventDto previewEvent = preview.get(5, TimeUnit.SECONDS);
+
+        SessionEventPreviewBus controllerPreviewBus = mock(SessionEventPreviewBus.class);
+        when(controllerPreviewBus.subscribe("session-1")).thenReturn(Flux.just(previewEvent));
         DataSessionService sessionService = mock(DataSessionService.class);
         SessionEventLog eventLog = mock(SessionEventLog.class);
         Authentication authentication = mock(Authentication.class);
@@ -512,7 +524,7 @@ void previewBusAndControllerPassUpdateAttributesThroughToSse() throws Exception
                 new DataSessionApiController(
                         sessionService,
                         eventLog,
-                        previewBus,
+                        controllerPreviewBus,
                         mock(ToolConfirmationCoordinator.class),
                         mock(SessionTurnRunner.class),
                         new ObjectMapper(),
@@ -527,12 +539,6 @@ void previewBusAndControllerPassUpdateAttributesThroughToSse() throws Exception
                         .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")
@@ -546,6 +552,67 @@ void previewBusAndControllerPassUpdateAttributesThroughToSse() throws Exception
         assertThat(((Map) payload.get("custom")).get("nested")).isEqualTo(true);
     }
 
+    @Test
+    void previewBusDoesNotReplayFramesEmittedBeforeSubscription() throws Exception {
+        SessionEventPreviewBus previewBus = new SessionEventPreviewBus();
+        previewBus.emitDelta(
+                "session-1", SessionEventTypes.AGENT_MESSAGE, "evt_stale", "stale preview");
+
+        CompletableFuture next =
+                previewBus.subscribe("session-1").next().toFuture();
+        previewBus.emitDelta(
+                "session-1", SessionEventTypes.AGENT_MESSAGE, "evt_live", "live preview");
+
+        SessionEventDto event = next.get(5, TimeUnit.SECONDS);
+        assertThat(event.payload())
+                .containsEntry("event_id", "evt_live")
+                .containsEntry("delta", "live preview");
+    }
+
+    @Test
+    void previewBusSerializesConcurrentSessionEmissions() throws Exception {
+        SessionEventPreviewBus previewBus = new SessionEventPreviewBus();
+        int eventsPerSession = 1_000;
+        CompletableFuture> sessionA =
+                previewBus.subscribe("session-a").take(eventsPerSession).collectList().toFuture();
+        CompletableFuture> sessionB =
+                previewBus.subscribe("session-b").take(eventsPerSession).collectList().toFuture();
+        CountDownLatch start = new CountDownLatch(1);
+
+        CompletableFuture producerA =
+                CompletableFuture.runAsync(
+                        () ->
+                                emitConcurrentFrames(
+                                        previewBus, "session-a", eventsPerSession, start));
+        CompletableFuture producerB =
+                CompletableFuture.runAsync(
+                        () ->
+                                emitConcurrentFrames(
+                                        previewBus, "session-b", eventsPerSession, start));
+        start.countDown();
+        CompletableFuture.allOf(producerA, producerB).get(5, TimeUnit.SECONDS);
+
+        assertThat(sessionA.get(5, TimeUnit.SECONDS)).hasSize(eventsPerSession);
+        assertThat(sessionB.get(5, TimeUnit.SECONDS)).hasSize(eventsPerSession);
+    }
+
+    private static void emitConcurrentFrames(
+            SessionEventPreviewBus previewBus, String sessionId, int count, CountDownLatch start) {
+        try {
+            start.await();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("Interrupted while waiting to emit preview frames", e);
+        }
+        for (int i = 0; i < count; i++) {
+            previewBus.emitDelta(
+                    sessionId,
+                    SessionEventTypes.AGENT_MESSAGE,
+                    "evt_" + sessionId + "_" + i,
+                    "delta-" + i);
+        }
+    }
+
     @Test
     void toolCallAccumulatesInputAndPersistsOnEnd() {
         assertThat(mapper.map(new ToolCallStartEvent("r", "tool-1", "bash"), previewIds).preview())