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/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/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/AgentEventStreams.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java new file mode 100644 index 0000000000..f148b684e3 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventStreams.java @@ -0,0 +1,196 @@ +/* + * 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 (isNormallyCompleted(event) && hasUnclassifiedText(current)) { + output.add( + disposition( + current.replyId(), TextOutputDisposition.TERMINAL, null, event)); + tracker.markDispositionEmitted(sourceKey); + } + output.add(event); + return output; + } + + private List complete() { + List output = new ArrayList<>(pendingTopLevelEnds.size() * 2); + for (Map.Entry entry : pendingTopLevelEnds.entrySet()) { + SourceKey sourceKey = entry.getKey(); + AgentEndEvent end = entry.getValue(); + ReplySnapshot current = tracker.snapshot(sourceKey); + AgentResultEvent result = current.lastResult(); + if (isNormallyCompleted(end) + && hasUnclassifiedText(current) + && result != null + && result.getResult() != null) { + GenerateReason reason = result.getResult().getGenerateReason(); + 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 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() + && !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/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/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..462f4630a3 --- /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() && taskId.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/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-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-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..47927ac134 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/event/AgentEventStreamsTest.java @@ -0,0 +1,458 @@ +/* + * 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.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; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import reactor.test.publisher.TestPublisher; + +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); + 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 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"); + 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 cancellationAfterTopLevelEndIsStagedDoesNotLeakTerminalOrEnd() { + TestPublisher source = TestPublisher.create(); + AgentResultEvent result = result(GenerateReason.MODEL_STOP); + AgentEndEvent end = new AgentEndEvent("reply-1"); + AgentEvent barrier = + tagged(new ModelCallStartEvent("barrier-reply"), "barrier-source", "barrier-task"); + + StepVerifier.create(AgentEventStreams.withTextOutputDisposition(source.flux()), 0) + .thenRequest(1) + .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) + .thenRequest(1) + .then(() -> source.next(end)) + .then(() -> source.next(barrier)) + .expectNext(barrier) + .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()); + } +} 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()); + } +} 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..a7f4f809ad --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/internal/stream/ReplyLifecycleTrackerTest.java @@ -0,0 +1,104 @@ +/* + * 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 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(); + 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()); + } +} 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..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 @@ -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,15 @@ import io.agentscope.core.tool.Toolkit; /** - * AgentEventStreamExample - Demonstrates {@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. 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. 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): *

@@ -48,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)
@@ -80,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();
@@ -94,7 +92,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 +104,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-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 0da7c14bcf..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,10 +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.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; @@ -45,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; @@ -61,27 +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 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 @@ -95,13 +111,33 @@ 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()); + + 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 @@ -146,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/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-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..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 @@ -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() : null; + } + 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/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 11c041b279..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 @@ -16,15 +16,26 @@ 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.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.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; 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.Collections; +import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -32,6 +43,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; @@ -43,7 +55,11 @@ 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 final String threadId; private final String runId; private final AguiAdapterConfig config; @@ -58,13 +74,21 @@ 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 textOutputDispositionsByReply = + 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 +116,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 +168,30 @@ 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")); + emitRememberedTextOutputDisposition(replyId); } + 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 +199,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 +219,64 @@ 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 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()) + || finalResult == null + || !FINAL_SNAPSHOT_REASONS.contains(finalResult.getGenerateReason()) + || !pendingInterrupts.isEmpty()) { + return; + } + Map messagesById = new LinkedHashMap<>(); + List authoritativeMessages = authoritativeMessagesSupplier.get(); + boolean hasAuthoritativeMessages = + authoritativeMessages != null && !authoritativeMessages.isEmpty(); + if (hasAuthoritativeMessages) { + for (Msg message : authoritativeMessages) { + if (message != null && !isGeneratedTextSegmentId(message.getId())) { + messagesById.put(message.getId(), messageConverter.toAguiMessage(message)); + } + } + } + if (runInput != null) { + for (AguiMessage message : runInput.getMessages()) { + if (message != null + && !isGeneratedTextSegmentId(message.getId()) + && (!hasAuthoritativeMessages + || messagesById.containsKey(message.getId()))) { + messagesById.put(message.getId(), message); + } + } + } + messagesById.put(finalResult.getId(), messageConverter.toAguiMessage(finalResult)); + emit( + new AguiEvent.MessagesSnapshot( + threadId, runId, new ArrayList<>(messagesById.values()))); + } + public void startReasoningMessage(String messageId) { String reasoningMessageId = reasoningMessageId(messageId); if (startedReasoningMessages.add(reasoningMessageId)) { @@ -331,6 +440,49 @@ 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 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 boolean isGeneratedTextSegmentId(String messageId) { + return messageId != null && startedTextMessages.contains(messageId); + } + private static String normalizeToolCallName(String toolCallName) { return toolCallName != null && !toolCallName.isBlank() ? toolCallName : "unknown"; } @@ -353,6 +505,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/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/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..b38c674f2e --- /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,36 @@ +/* + * 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.event.AgentEvent; +import io.agentscope.core.event.TextOutputDispositionEvent; +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"; + + @Override + public Set> eventTypes() { + return Set.of(TextOutputDispositionEvent.class); + } + + @Override + public void convert(AgentEvent event, AguiStreamContext context) { + context.emitTextOutputDisposition((TextOutputDispositionEvent) event); + } +} 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/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..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 @@ -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; @@ -50,11 +57,13 @@ 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; 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; @@ -67,15 +76,22 @@ 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; import io.agentscope.core.tool.SchemaOnlyTool; import io.agentscope.core.tool.Toolkit; import io.agentscope.harness.agent.HarnessAgent; @@ -86,6 +102,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 +326,481 @@ 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"))); + } + + @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, + 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, + 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, + 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 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 testFinalSnapshotExcludesOnlySegmentsCreatedByCurrentRun() { + Msg user = Msg.builder().id("session-user").role(MsgRole.USER).textContent("q").build(); + Msg generatedSegment = + AssistantMessage.builder() + .id("reply-final:text:0") + .content(TextBlock.builder().text("preview").build()) + .build(); + Msg legitimatePatternId = + AssistantMessage.builder() + .id("order:text:0") + .content(TextBlock.builder().text("kept").build()) + .build(); + AgentState state = + AgentState.builder() + .context(List.of(user, generatedSegment, legitimatePatternId)) + .build(); + RuntimeContext callerContext = RuntimeContext.builder().agentState(state).build(); + + List events = + runTerminalDisposition(GenerateReason.MODEL_STOP, callerContext); + + assertEquals( + List.of("session-user", "order:text:0", "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 = @@ -498,6 +991,55 @@ 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(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( + 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(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 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(); + } } 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/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..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 @@ -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; @@ -62,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) { @@ -74,7 +78,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 +241,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/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-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..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; @@ -69,6 +70,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; @@ -771,7 +773,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 +785,30 @@ 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 = - () -> { + Consumer emitEnd = + outcome -> { if (endEmitted.compareAndSet(false, true)) { parentEmitter.emit( - new AgentEndEvent(replyId).withSource(sourcePath)); + tagForwardedEvent( + new AgentEndEvent(replyId) + .withMetadataEntry( + AgentEndEvent + .METADATA_INVOCATION_OUTCOME, + outcome), + sourcePath, + taskId)); } }; @@ -805,14 +820,24 @@ 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( + 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. .doFinally( signal -> { if (signal == SignalType.CANCEL) { - emitEnd.run(); + emitEnd.accept(AgentEndEvent.OUTCOME_CANCELLED); } }); } @@ -895,6 +920,7 @@ private Mono execWithTimeoutPromotion( Mono.create( sink -> { CompletableFuture bridge = new CompletableFuture<>(); + String taskId = "task_" + UUID.randomUUID(); Mono inner = execLocalSync( @@ -903,7 +929,8 @@ private Mono execWithTimeoutPromotion( userId, task, spawned, - runtimeContext) + runtimeContext, + taskId) .contextWrite( c -> reactor.util.context.Context.of( @@ -954,6 +981,7 @@ private Mono execWithTimeoutPromotion( header, timeoutMs, agentId, + taskId, sink, forceSync, innerSub); @@ -1005,6 +1033,7 @@ private void handleExecError( String header, long timeoutMs, String agentId, + String taskId, reactor.core.publisher.MonoSink sink, boolean forceSync, Disposable innerSub) { @@ -1024,7 +1053,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 +1223,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 +1239,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..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 @@ -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 @@ -369,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-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 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-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-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 = diff --git a/agentscope-service/docs/managed_agents/guide/07-events.md b/agentscope-service/docs/managed_agents/guide/07-events.md index 7def6b22c4..8b3173b36b 100644 --- a/agentscope-service/docs/managed_agents/guide/07-events.md +++ b/agentscope-service/docs/managed_agents/guide/07-events.md @@ -26,10 +26,24 @@ curl -N "$BASE/api/sessions/$SESSION_ID/events/stream?event_deltas=agent.message |---|---| | `event_start` | 即将产生某持久化类型;payload 含 `event_id`、`type` | | `event_delta` | 增量文本;payload 含 `event_id`、`type`、`delta` | +| `event_update` | 更新同一预览的文本处置;权威结果无输出时也用于清空预览;payload 含 `event_id`、`type` 及状态字段 | 完整 `agent.message` / `agent.thinking` 仍会在落库后推送。 `GET …/events` **永远看不到** delta。多副本下 deltas 仅 turn-owner best-effort。 +Managed Web 的 turn runner 已在服务端启用文本处置派生;客户端无需增加服务端配置,只需像上例一样订阅 +`event_deltas=agent.message`,即可收到同一 `event_id` 的 `event_update`: + +- `disposition=INTERMEDIATE`:当前预览只是过程文本,UI 可降级为 commentary。 +- `disposition=TERMINAL`:当前预览的文本生命周期结束;这**不等于最终答案**。 +- `authoritative=true, hasOutput=false`:仅在权威 `AgentResultEvent` 没有输出时发送,用于清除此前预览。 +- 普通非空权威结果**不会**另发 `authoritative=true` 的 `event_update`;最终持久化的 `agent.message` 会复用 + 该 `event_id` 并携带权威内容,以此校准或替换预览。 + +`event_update` 与 delta 一样只存在于 SSE 流中,不会落库。最终答案应以权威 +`AgentResultEvent` 映射出的 `agent.message`(或 `authoritative=true, hasOutput=false` 的空结果更新)为准,而不是仅凭 +`TERMINAL` 判定。 + ## 投递入站 ```bash diff --git a/agentscope-service/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..731a08fa40 --- /dev/null +++ b/agentscope-service/frontend/src/components/ChatPanel.test.tsx @@ -0,0 +1,196 @@ +/* + * 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 { 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, + 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 }); +} + +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(); + 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(); + }); + + 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, deltaFrame('reply-1', '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', '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( + , + ); + 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 898f5655ae..ee44b41cae 100644 --- a/agentscope-service/frontend/src/components/ChatPanel.tsx +++ b/agentscope-service/frontend/src/components/ChatPanel.tsx @@ -31,10 +31,11 @@ import MessageBlock, { ContentBlock } from './MessageBlock'; type Role = 'user' | 'assistant' | 'system' | 'error'; -interface Message { +export 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,358 @@ interface PendingConfirmation { input?: Record; } +export interface ChatTextSegment { + id: string; + sourceId: string; + text: string; + pending: boolean; +} + +export interface ChatToolExecution { + id: string; + toolName: string; + text?: string; + 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; + activeCommentarySegmentIds: Record; + knownDispositions: Record; + nextSequence: number; +} + +export function createChatDisplayState(): ChatDisplayState { + return { + pendingSegments: {}, + commentarySegments: {}, + thinkingSegments: {}, + toolExecutions: {}, + displayOrder: [], + activePendingSegmentIds: {}, + activeCommentarySegmentIds: {}, + knownDispositions: {}, + nextSequence: 1, + }; +} + +function cloneDisplayState(state: ChatDisplayState): ChatDisplayState { + return { + ...state, + pendingSegments: { ...state.pendingSegments }, + commentarySegments: { ...state.commentarySegments }, + thinkingSegments: { ...state.thinkingSegments }, + toolExecutions: { ...state.toolExecutions }, + displayOrder: [...state.displayOrder], + activePendingSegmentIds: { ...state.activePendingSegmentIds }, + activeCommentarySegmentIds: { ...state.activeCommentarySegmentIds }, + knownDispositions: { ...state.knownDispositions }, + }; +} + +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 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; + 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 ?? {}; + 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') { + const next = cloneDisplayState(state); + 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; + } + return state; + } + + if (event.type === 'event_delta') { + const delta = payload.delta != null ? String(payload.delta) : ''; + if (!eventId || !delta) return state; + if (targetType === 'agent.message') { + const next = cloneDisplayState(state); + 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') { + 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 next = cloneDisplayState(state); + closeActiveCommentarySegments(next); + const current = ensureToolExecution(next, eventId); + next.toolExecutions[eventId] = { + ...current, + text: `${current.text ?? ''}${delta}`, + }; + return next; + } + return state; + } + + if (event.type === 'event_update' && targetType === 'agent.message' && eventId) { + const attributes = { ...payload, ...(event.attributes ?? {}) }; + if (attributes.disposition === 'INTERMEDIATE') { + const segmentId = state.activePendingSegmentIds[eventId]; + const pending = segmentId ? state.pendingSegments[segmentId] : undefined; + 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); + return next; + } + if (attributes.disposition === 'TERMINAL') { + const segmentId = state.activePendingSegmentIds[eventId]; + const pending = segmentId ? state.pendingSegments[segmentId] : undefined; + 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 + ? { ...entry, presentation: 'preview' } + : entry); + return next; + } + if (attributes.authoritative === true && attributes.hasOutput === false) { + 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 next = cloneDisplayState(state); + 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.keys(next.pendingSegments); + for (const id of removedIds) delete next.pendingSegments[id]; + next.activePendingSegmentIds = {}; + next.activeCommentarySegmentIds = {}; + next.knownDispositions = {}; + 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 next = cloneDisplayState(state); + closeActiveCommentarySegments(next); + 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 ?? source?.toolName ?? 'tool'), + text: source?.text ?? (payload.input != null ? JSON.stringify(payload.input) : undefined), + result: source?.result, + }; + 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') { + 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[] { + 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; const S: Record = { @@ -127,7 +480,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. @@ -156,12 +514,16 @@ 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, + presentation: 'final', + }); + } } else if (evt.type === 'agent.tool_use') { ensureOpen(evt.id).blocks.push({ kind: 'tool', @@ -201,7 +563,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 { @@ -320,63 +771,54 @@ 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); - } - } - openMsgIdRef.current = `${seedId}-turn`; - return [...prev, { id: `${seedId}-turn`, role: 'assistant', blocks: [block], pending: true }]; + const updateDisplay = (prev: Message[]): Message[] => { + const next = applyAssistantFrameToHistory( + { messages: prev, openMessageId: openMsgIdRef.current }, + evt, + ); + openMsgIdRef.current = next.openMessageId; + return next.messages; }; - 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 +841,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 @@ -531,7 +895,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); }, @@ -800,7 +1166,7 @@ export default function ChatPanel({ 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 && (
)} 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..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 @@ -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); return MappingResult.previewOnly( new PreviewFrame( SessionEventTypes.EVENT_DELTA, @@ -130,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, @@ -138,21 +157,37 @@ public MappingResult map(AgentEvent event, PreviewIds previewIds) { eventId, thinking.getDelta())); } + if (event instanceof TextOutputDispositionEvent disposition) { + String eventId = previewIds.dispositionMessageEventId(disposition); + if (eventId == null) { + return MappingResult.empty(); + } + if (disposition.getDisposition() == TextOutputDisposition.INTERMEDIATE) { + previewIds.markIntermediate(disposition); + } + 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 (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( @@ -165,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, @@ -176,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()); @@ -195,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) { @@ -204,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()); @@ -232,27 +261,124 @@ 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.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); 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 (!previewIds.isTopLevel(end)) { + return MappingResult.empty(); + } + return commitTopLevelResult(end, previewIds); + } + if (event instanceof AgentStartEvent) { return MappingResult.empty(); } return MappingResult.empty(); } + private MappingResult commitTopLevelResult(AgentEndEvent end, PreviewIds previewIds) { + 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(); + } + + 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,63 +403,140 @@ 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 String thinkingId; - 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) {} + + private record ToolKey(InvocationKey invocation, String toolCallId) {} - public String messageEventId() { - if (messageId == null) { - messageId = newEventId(); + 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<>(); + 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 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 messageId; + return eventId; + } + + /** Returns and clears the preview id finalized for this invocation. */ + public String consumeMessageEventId(AgentEndEvent event) { + 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); + } + + public void markIntermediate(TextOutputDispositionEvent event) { + intermediateReplies.add(previewKey(event, event.getReplyId())); } - /** 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 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(previewKey(event, event.getReplyId())); } - public String thinkingEventId() { - if (thinkingId == null) { - thinkingId = newEventId(); + public void finishModelCall(ModelCallEndEvent event) { + PreviewKey previewKey = previewKey(event, event.getReplyId()); + if (intermediateReplies.remove(previewKey)) { + messageIdsByReply.remove(previewKey); } - return thinkingId; } - public String toolUseEventId(String toolCallId) { - return beginToolUse(toolCallId, null).eventId(); + public boolean isTopLevel(AgentEvent event) { + return invocationKey(event).equals(new InvocationKey("", "")); + } + + public void rememberTopLevelResult(AgentResultEvent result) { + topLevelResults.put(invocationKey(result), result); + } + + 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(ToolCallStartEvent event) { + return beginToolUse(event).eventId(); + } + + public String toolUseEventId(ToolCallDeltaEvent event) { + return beginToolUse(event, event.getToolCallId(), event.getToolCallName()).eventId(); } - public ToolBuffers.ToolUseBuffer beginToolUse(String toolCallId, String toolName) { - String key = key(toolCallId); + 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); @@ -341,28 +544,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); + toolKey, + ignored -> + new ToolBuffers.ToolResultBuffer( + newEventId(), event.getToolCallName())); + if (event.getToolCallName() != null) { + buf.setToolName(event.getToolCallName()); } - toolResults.remove(key(toolCallId)); + toolResults.remove(toolKey); return buf; } - public void resetMessage() { - messageId = 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())); } - public void resetThinking() { - thinkingId = null; + 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/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..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,20 +18,19 @@ 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; /** - * 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 { - 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) { @@ -50,6 +49,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,24 +69,21 @@ 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()); } } 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/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..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 @@ -16,24 +16,44 @@ 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.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.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.CountDownLatch; +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 +83,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 +135,38 @@ 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); + } + + @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); } @@ -120,11 +193,426 @@ 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 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 : + 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(); + 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); + when(authentication.getPrincipal()).thenReturn("user-1"); + when(eventLog.subscribe("session-1", 0L)).thenReturn(Flux.never()); + + DataSessionApiController controller = + new DataSessionApiController( + sessionService, + eventLog, + controllerPreviewBus, + 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(); + + 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 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()) @@ -165,4 +653,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); + } }