diff --git a/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/AgentEventAguiReplayer.java b/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/AgentEventAguiReplayer.java
index 793118797a..87a0d7408a 100644
--- a/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/AgentEventAguiReplayer.java
+++ b/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/AgentEventAguiReplayer.java
@@ -39,6 +39,10 @@
* and resets workbench snapshot baselines per historical run so {@code STATE_SNAPSHOT} /
* {@code STATE_DELTA} projection matches a fresh conversion.
*
+ *
Presentation replay for reconnect (resolved-interrupt suppression and dangling tool-call
+ * synthesis) has moved to the framework presentation snapshot store; this replayer now serves the
+ * {@code /threads/{id}/events} inspect API, which is legitimately an event log rather than
+ * presentation state.
*/
@Component
public final class AgentEventAguiReplayer {
diff --git a/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/CopilotKitRuntimeService.java b/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/CopilotKitRuntimeService.java
index a39db89094..cc054ae7ae 100644
--- a/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/CopilotKitRuntimeService.java
+++ b/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/CopilotKitRuntimeService.java
@@ -19,22 +19,24 @@
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.RunAgentInput;
import io.agentscope.core.agui.registry.AguiAgentRegistry;
+import io.agentscope.core.agui.store.AguiSnapshotHydrator;
+import io.agentscope.core.agui.store.AguiSnapshotStore;
+import io.agentscope.core.agui.store.AguiThreadSnapshot;
import io.agentscope.examples.copilotkit.model.CopilotKitModels.AgentInfo;
import io.agentscope.examples.copilotkit.model.CopilotKitModels.InfoResponse;
import io.agentscope.examples.copilotkit.model.CopilotKitModels.Intelligence;
import io.agentscope.examples.copilotkit.model.CopilotKitModels.ThreadEndpoints;
-import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
/**
- * CopilotKit Runtime info and multi-route connect handshake with AgentEvent replay.
- *
- *
+ * CopilotKit Runtime info and multi-route connect hydrate backed by the framework presentation
+ * snapshot store.
*/
@Service
public final class CopilotKitRuntimeService {
@@ -47,13 +49,15 @@ public final class CopilotKitRuntimeService {
"humanInTheLoop", true);
private final AguiAgentRegistry aguiAgentRegistry;
- private final AgentEventAguiReplayer eventReplayer;
+ private final ObjectProvider snapshotStoreProvider;
+ private final AguiSnapshotHydrator hydrator = new AguiSnapshotHydrator();
private final AguiEventEncoder encoder = new AguiEventEncoder();
public CopilotKitRuntimeService(
- AguiAgentRegistry aguiAgentRegistry, AgentEventAguiReplayer eventReplayer) {
+ AguiAgentRegistry aguiAgentRegistry,
+ ObjectProvider snapshotStoreProvider) {
this.aguiAgentRegistry = aguiAgentRegistry;
- this.eventReplayer = eventReplayer;
+ this.snapshotStoreProvider = snapshotStoreProvider;
}
public InfoResponse info() {
@@ -105,29 +109,22 @@ private AgentInfo resolveAgentInfo(String agentId) {
}
/**
- * AG-UI connect: replay persisted AgentEvents through converters, or emit an empty handshake.
+ * AG-UI connect: rebuild the visible conversation from the framework presentation snapshot
+ * store.
*
- * History is stored as AgentScope {@code AgentEvent}s. On connect they are projected to
- * AG-UI frames with the same converter registry used by {@code /run}, so CopilotKit can
- * restore the conversation. Without history a minimal
- * {@code RUN_STARTED → MESSAGES_SNAPSHOT([]) → RUN_FINISHED} handshake is returned.
+ *
Read-only: it looks up the stored snapshot for the thread and delegates to
+ * {@link AguiSnapshotHydrator}. When the snapshot store is disabled (or the thread has no
+ * history) the hydrator returns the minimal {@code RUN_STARTED → MESSAGES_SNAPSHOT([]) →
+ * RUN_FINISHED} handshake. Only the trailing unresolved interrupt is ever replayed, so a
+ * resolved historical interrupt can never reappear.
*/
public Flux> connect(RunAgentInput input) {
String threadId = input.getThreadId();
String runId = input.getRunId();
- List history = eventReplayer.replay(threadId, input);
- if (history.isEmpty()) {
- return Flux.fromIterable(emptyHandshake(threadId, runId)).map(this::sse);
- }
- return Flux.fromIterable(history).map(this::sse);
- }
-
- private List emptyHandshake(String threadId, String runId) {
- List events = new ArrayList<>(3);
- events.add(new AguiEvent.RunStarted(threadId, runId));
- events.add(new AguiEvent.MessagesSnapshot(threadId, runId, List.of()));
- events.add(new AguiEvent.RunFinished(threadId, runId));
- return events;
+ AguiSnapshotStore store = snapshotStoreProvider.getIfAvailable();
+ AguiThreadSnapshot snapshot = store != null ? store.find(threadId).orElse(null) : null;
+ List frames = hydrator.hydrate(snapshot, threadId, runId);
+ return Flux.fromIterable(frames).map(this::sse);
}
private ServerSentEvent sse(AguiEvent event) {
diff --git a/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/PersistingAgentEventEnricher.java b/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/PersistingAgentEventEnricher.java
index 392a13266c..591d1e61c5 100644
--- a/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/PersistingAgentEventEnricher.java
+++ b/agentscope-examples/agentscope-copilotkit/src/main/java/io/agentscope/examples/copilotkit/service/PersistingAgentEventEnricher.java
@@ -29,7 +29,10 @@
/**
* Persists the source {@link AgentEvent} while the AG-UI adapter projects it.
*
- * AG-UI frames themselves are not stored — connect replay re-projects through converters.
+ *
AG-UI frames themselves are not stored. Presentation replay for reconnect has moved to the
+ * framework presentation snapshot store ({@link io.agentscope.core.agui.store.AguiSnapshotStore});
+ * this enricher now serves only the {@code /threads/{id}/events} inspect API, which is
+ * legitimately an event log rather than presentation state.
*
*
On {@link AgentStartEvent}, also stores the run's input messages so reconnect can rebuild
* {@code RUN_STARTED.input.messages} (how CopilotKit restores user / tool turns).
diff --git a/agentscope-examples/agentscope-copilotkit/src/main/resources/application.yml b/agentscope-examples/agentscope-copilotkit/src/main/resources/application.yml
index 91a3df343d..ea6d6394db 100644
--- a/agentscope-examples/agentscope-copilotkit/src/main/resources/application.yml
+++ b/agentscope-examples/agentscope-copilotkit/src/main/resources/application.yml
@@ -54,6 +54,10 @@ agentscope:
max-thread-sessions: 1000
session-timeout-minutes: 30
enable-reasoning: true
+ # Presentation snapshot store: enables POST /agui/connect hydrate so reconnecting clients
+ # rebuild the visible conversation without re-running the agent.
+ snapshot-store-enabled: true
+ snapshot-max-threads: 1000
# Logging
logging:
diff --git a/agentscope-examples/agui/README.md b/agentscope-examples/agui/README.md
new file mode 100644
index 0000000000..e49e4f1aa5
--- /dev/null
+++ b/agentscope-examples/agui/README.md
@@ -0,0 +1,49 @@
+# AG-UI Example
+
+A minimal Spring Boot WebFlux application exposing AgentScope agents over the AG-UI protocol.
+
+## Run
+
+```bash
+mvn -q -pl agentscope-examples/agui spring-boot:run
+```
+
+The server listens on `http://localhost:8080` and exposes `POST /agui/run` (and `/agui/run/{agentId}`
+when path routing is enabled).
+
+## Presentation Snapshot Hydrate
+
+This example enables the AG-UI presentation snapshot store:
+
+```yaml
+agentscope:
+ agui:
+ snapshot-store-enabled: true
+ snapshot-max-threads: 1000
+```
+
+With the store enabled, a reconnecting client can rebuild the visible conversation **without
+re-running the agent** by calling the read-only hydrate endpoint `POST /agui/connect`.
+
+Try it:
+
+1. Run the agent once against `/agui/run` with a `threadId`:
+
+ ```bash
+ curl -N http://localhost:8080/agui/run \
+ -H 'Content-Type: application/json' \
+ -d '{"threadId":"demo-1","runId":"run-1","messages":[{"id":"m1","role":"user","content":"hello"}]}'
+ ```
+
+2. Replay the same `threadId` against `/agui/connect` and observe a `MESSAGES_SNAPSHOT` restoring
+ the conversation with **no model call**:
+
+ ```bash
+ curl -N http://localhost:8080/agui/connect \
+ -H 'Content-Type: application/json' \
+ -d '{"threadId":"demo-1","runId":"connect-1"}'
+ ```
+
+The hydrate response is strictly read-only: it never mutates the agent, the snapshot store, or the
+resume coordinator, and only the **trailing unresolved** interrupt is ever replayed (so a resolved
+historical interrupt cannot reappear).
diff --git a/agentscope-examples/agui/src/main/resources/application.yml b/agentscope-examples/agui/src/main/resources/application.yml
index 2d55e3ac89..700b9a1131 100644
--- a/agentscope-examples/agui/src/main/resources/application.yml
+++ b/agentscope-examples/agui/src/main/resources/application.yml
@@ -52,6 +52,10 @@ agentscope:
max-thread-sessions: 1000
session-timeout-minutes: 30
enable-reasoning: true
+ # Presentation snapshot store: enables POST /agui/connect hydrate so reconnecting clients
+ # rebuild the visible conversation without re-running the agent.
+ snapshot-store-enabled: true
+ snapshot-max-threads: 1000
# Logging
logging:
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..21e2425bf9 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
@@ -19,6 +19,8 @@
import io.agentscope.core.agui.adapter.strategy.AguiEventEnricher;
import io.agentscope.core.agui.adapter.strategy.BaseEventPropertiesEnricher;
import io.agentscope.core.agui.model.ToolMergeMode;
+import io.agentscope.core.agui.store.AguiSnapshotStore;
+import io.agentscope.core.agui.store.SnapshotRecordingEnricher;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@@ -45,6 +47,9 @@ public class AguiAdapterConfig {
private final List eventEnrichers;
private final boolean baseEventPropertiesEnricherEnabled;
private final boolean emitSubagentEventsAsNative;
+ private final boolean snapshotStoreEnabled;
+ private final AguiSnapshotStore snapshotStore;
+ private final SnapshotRecordingEnricher snapshotRecorder;
private AguiAdapterConfig(Builder builder) {
this.toolMergeMode = builder.toolMergeMode;
@@ -56,9 +61,34 @@ private AguiAdapterConfig(Builder builder) {
this.runTimeout = builder.runTimeout;
this.defaultAgentId = builder.defaultAgentId;
this.eventConverters = List.copyOf(builder.eventConverters);
- this.eventEnrichers = buildEventEnrichers(builder);
+ SnapshotRecordingEnricher recorder = recorderFrom(builder);
+ this.eventEnrichers = buildEventEnrichers(builder, recorder);
this.baseEventPropertiesEnricherEnabled = builder.baseEventPropertiesEnricherEnabled;
this.emitSubagentEventsAsNative = builder.emitSubagentEventsAsNative;
+ this.snapshotStoreEnabled = builder.snapshotStoreEnabled;
+ this.snapshotStore = builder.snapshotStore;
+ this.snapshotRecorder = recorder;
+ }
+
+ private static List buildEventEnrichers(
+ Builder builder, SnapshotRecordingEnricher recorder) {
+ List enrichers = new ArrayList<>();
+ if (builder.baseEventPropertiesEnricherEnabled) {
+ enrichers.add(new BaseEventPropertiesEnricher());
+ }
+ enrichers.addAll(builder.eventEnrichers);
+ if (recorder != null) {
+ // Appended last so it observes fully enriched frames from all converters.
+ enrichers.add(recorder);
+ }
+ return List.copyOf(enrichers);
+ }
+
+ private static SnapshotRecordingEnricher recorderFrom(Builder builder) {
+ if (!builder.snapshotStoreEnabled || builder.snapshotStore == null) {
+ return null;
+ }
+ return new SnapshotRecordingEnricher(builder.snapshotStore);
}
/**
@@ -183,6 +213,40 @@ public boolean isEmitSubagentEventsAsNative() {
return emitSubagentEventsAsNative;
}
+ /**
+ * Check whether the AG-UI presentation snapshot store is enabled.
+ *
+ * When {@code true} and {@link #getSnapshotStore()} is set, a {@link
+ * SnapshotRecordingEnricher} is appended last in the enricher chain so reconnecting clients
+ * can rebuild the visible conversation via {@code POST {path-prefix}/connect}. Default is
+ * {@code false} so existing clients stay byte-identical.
+ *
+ * @return true if the snapshot store is enabled
+ */
+ public boolean isSnapshotStoreEnabled() {
+ return snapshotStoreEnabled;
+ }
+
+ /**
+ * Get the configured presentation snapshot store, or null when disabled.
+ *
+ * @return the snapshot store, or null
+ */
+ public AguiSnapshotStore getSnapshotStore() {
+ return snapshotStore;
+ }
+
+ /**
+ * Get the recording enricher appended to the enricher chain, or null when the snapshot store is
+ * disabled. This is the single instance shared with the chain, so callers (e.g. the request
+ * processor's flush safety net) target the same accumulator that is recording the live stream.
+ *
+ * @return the snapshot recording enricher, or null
+ */
+ public SnapshotRecordingEnricher getSnapshotRecorder() {
+ return snapshotRecorder;
+ }
+
/**
* Creates a new builder for AguiAdapterConfig.
*
@@ -207,6 +271,10 @@ private static List buildEventEnrichers(Builder builder) {
enrichers.add(new BaseEventPropertiesEnricher());
}
enrichers.addAll(builder.eventEnrichers);
+ if (builder.snapshotStoreEnabled && builder.snapshotStore != null) {
+ // Appended last so it observes fully enriched frames from all converters.
+ enrichers.add(new SnapshotRecordingEnricher(builder.snapshotStore));
+ }
return List.copyOf(enrichers);
}
@@ -227,6 +295,8 @@ public static class Builder {
private final List eventEnrichers = new ArrayList<>();
private boolean baseEventPropertiesEnricherEnabled = false;
private boolean emitSubagentEventsAsNative = false;
+ private boolean snapshotStoreEnabled = false;
+ private AguiSnapshotStore snapshotStore;
/**
* Set the tool merge mode.
@@ -408,6 +478,29 @@ public Builder emitSubagentEventsAsNative(boolean emitSubagentEventsAsNative) {
return this;
}
+ /**
+ * Enable the AG-UI presentation snapshot store so a {@link SnapshotRecordingEnricher} is
+ * appended last in the enricher chain.
+ *
+ * @param snapshotStoreEnabled true to enable
+ * @return This builder
+ */
+ public Builder snapshotStoreEnabled(boolean snapshotStoreEnabled) {
+ this.snapshotStoreEnabled = snapshotStoreEnabled;
+ return this;
+ }
+
+ /**
+ * Set the presentation snapshot store used to record and hydrate thread state.
+ *
+ * @param snapshotStore the store, or null
+ * @return This builder
+ */
+ public Builder snapshotStore(AguiSnapshotStore snapshotStore) {
+ this.snapshotStore = snapshotStore;
+ return this;
+ }
+
/**
* Build the configuration.
*
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiActivityConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiActivityConverter.java
new file mode 100644
index 0000000000..a4dae8ac4b
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiActivityConverter.java
@@ -0,0 +1,89 @@
+/*
+ * 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.converter;
+
+import io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.agui.event.AguiEvent.JsonPatchOperation;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Converter for AG-UI activity events, mirroring {@link AguiStateConverter}.
+ *
+ * Creates {@link AguiEvent.ActivitySnapshot} and {@link AguiEvent.ActivityDelta} events from
+ * before/after activity content maps. Delta computation reuses {@link AguiJsonDiff} so activity
+ * deltas are byte-compatible with state deltas.
+ */
+public class AguiActivityConverter {
+
+ /**
+ * Create an {@link AguiEvent.ActivitySnapshot} event.
+ *
+ * @param threadId the thread id
+ * @param runId the run id
+ * @param messageId the message id the activity is attached to
+ * @param activityType the activity type
+ * @param content the activity content
+ * @param replace whether the snapshot replaces prior content for this (messageId, activityType)
+ * @return the activity snapshot event
+ */
+ public AguiEvent.ActivitySnapshot createSnapshot(
+ String threadId,
+ String runId,
+ String messageId,
+ String activityType,
+ Map content,
+ boolean replace) {
+ return new AguiEvent.ActivitySnapshot(
+ threadId, runId, messageId, activityType, content, replace);
+ }
+
+ /**
+ * Create an {@link AguiEvent.ActivityDelta} event by comparing before and after content.
+ *
+ * @param threadId the thread id
+ * @param runId the run id
+ * @param messageId the message id the activity is attached to
+ * @param activityType the activity type
+ * @param before the activity content before changes
+ * @param after the activity content after changes
+ * @return the activity delta event, or null if there are no changes
+ */
+ public AguiEvent.ActivityDelta createDelta(
+ String threadId,
+ String runId,
+ String messageId,
+ String activityType,
+ Map before,
+ Map after) {
+ List operations = AguiJsonDiff.computeDelta(before, after, "");
+ if (operations.isEmpty()) {
+ return null;
+ }
+ return new AguiEvent.ActivityDelta(threadId, runId, messageId, activityType, operations);
+ }
+
+ /**
+ * Check if there are any differences between two activity content maps.
+ *
+ * @param before the content before changes
+ * @param after the content after changes
+ * @return true if there are differences
+ */
+ public boolean hasChanges(Map before, Map after) {
+ return AguiJsonDiff.hasChanges(before, after);
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiJsonDiff.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiJsonDiff.java
new file mode 100644
index 0000000000..2439cd526b
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiJsonDiff.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.core.agui.converter;
+
+import io.agentscope.core.agui.event.AguiEvent.JsonPatchOperation;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Shared RFC 6902 diff helpers used by {@link AguiStateConverter} and
+ * {@link AguiActivityConverter}.
+ *
+ * Emits only {@code add} / {@code remove} / {@code replace} operations with RFC 6901 escaping
+ * so the result is symmetric with {@link io.agentscope.core.agui.store.AguiJsonPatch#apply}.
+ */
+final class AguiJsonDiff {
+
+ private AguiJsonDiff() {}
+
+ /**
+ * Compute the JSON Patch operations needed to transform {@code before} into {@code after}.
+ *
+ * @param before the state before changes, may be null
+ * @param after the state after changes, may be null
+ * @param basePath the base JSON Pointer path
+ * @return list of patch operations, never null
+ */
+ @SuppressWarnings("unchecked")
+ static List computeDelta(
+ Map before, Map after, String basePath) {
+ List operations = new ArrayList<>();
+
+ if (before == null && after == null) {
+ return operations;
+ }
+ if (before == null) {
+ before = Map.of();
+ }
+ if (after == null) {
+ after = Map.of();
+ }
+
+ Set allKeys = new HashSet<>();
+ allKeys.addAll(before.keySet());
+ allKeys.addAll(after.keySet());
+
+ for (String key : allKeys) {
+ String path = basePath + "/" + escapeJsonPointer(key);
+ Object beforeValue = before.get(key);
+ Object afterValue = after.get(key);
+
+ if (!before.containsKey(key)) {
+ operations.add(JsonPatchOperation.add(path, afterValue));
+ } else if (!after.containsKey(key)) {
+ operations.add(JsonPatchOperation.remove(path));
+ } else if (!Objects.equals(beforeValue, afterValue)) {
+ if (beforeValue instanceof Map && afterValue instanceof Map) {
+ operations.addAll(
+ computeDelta(
+ (Map) beforeValue,
+ (Map) afterValue,
+ path));
+ } else {
+ operations.add(JsonPatchOperation.replace(path, afterValue));
+ }
+ }
+ }
+
+ return operations;
+ }
+
+ /**
+ * Check whether two states differ.
+ *
+ * @param before the state before changes, may be null
+ * @param after the state after changes, may be null
+ * @return true if there are differences
+ */
+ static boolean hasChanges(Map before, Map after) {
+ return !computeDelta(before, after, "").isEmpty();
+ }
+
+ /**
+ * Escape a string for use in a JSON Pointer (RFC 6901).
+ *
+ * Per RFC 6901, {@code ~} must be escaped as {@code ~0} and {@code /} as {@code ~1}.
+ *
+ * @param value the string to escape
+ * @return the escaped string
+ */
+ static String escapeJsonPointer(String value) {
+ return value.replace("~", "~0").replace("/", "~1");
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiStateConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiStateConverter.java
index 65c3abe32c..fe7c2c98ae 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiStateConverter.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiStateConverter.java
@@ -17,12 +17,8 @@
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.event.AguiEvent.JsonPatchOperation;
-import java.util.ArrayList;
-import java.util.HashSet;
import java.util.List;
import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
/**
* Converter for state management in the AG-UI protocol.
@@ -59,7 +55,7 @@ public AguiEvent.StateSnapshot createSnapshot(
*/
public AguiEvent.StateDelta createDelta(
Map before, Map after, String threadId, String runId) {
- List operations = computeDelta(before, after, "");
+ List operations = AguiJsonDiff.computeDelta(before, after, "");
if (operations.isEmpty()) {
return null; // No changes
@@ -76,76 +72,6 @@ public AguiEvent.StateDelta createDelta(
* @return true if there are differences
*/
public boolean hasChanges(Map before, Map after) {
- return !computeDelta(before, after, "").isEmpty();
- }
-
- /**
- * Compute the JSON Patch operations needed to transform "before" into "after".
- *
- * @param before The state before changes
- * @param after The state after changes
- * @param basePath The base JSON Pointer path
- * @return List of JsonPatchOperations
- */
- @SuppressWarnings("unchecked")
- private List computeDelta(
- Map before, Map after, String basePath) {
- List operations = new ArrayList<>();
-
- if (before == null && after == null) {
- return operations;
- }
-
- if (before == null) {
- before = Map.of();
- }
-
- if (after == null) {
- after = Map.of();
- }
-
- Set allKeys = new HashSet<>();
- allKeys.addAll(before.keySet());
- allKeys.addAll(after.keySet());
-
- for (String key : allKeys) {
- String path = basePath + "/" + escapeJsonPointer(key);
- Object beforeValue = before.get(key);
- Object afterValue = after.get(key);
-
- if (!before.containsKey(key)) {
- // Key was added
- operations.add(JsonPatchOperation.add(path, afterValue));
- } else if (!after.containsKey(key)) {
- // Key was removed
- operations.add(JsonPatchOperation.remove(path));
- } else if (!Objects.equals(beforeValue, afterValue)) {
- // Value changed
- if (beforeValue instanceof Map && afterValue instanceof Map) {
- // Recurse into nested maps
- operations.addAll(
- computeDelta(
- (Map) beforeValue,
- (Map) afterValue,
- path));
- } else {
- // Replace value
- operations.add(JsonPatchOperation.replace(path, afterValue));
- }
- }
- }
-
- return operations;
- }
-
- /**
- * Escape a string for use in a JSON Pointer (RFC 6901).
- *
- * @param value The string to escape
- * @return The escaped string
- */
- private String escapeJsonPointer(String value) {
- // Per RFC 6901, ~ must be escaped as ~0 and / as ~1
- return value.replace("~", "~0").replace("/", "~1");
+ return AguiJsonDiff.hasChanges(before, after);
}
}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java
index e45e2ad92d..68ab3fc9ca 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java
@@ -27,6 +27,10 @@
import io.agentscope.core.agui.model.RunAgentInput;
import io.agentscope.core.agui.runtime.AguiRuntimeContextRequest;
import io.agentscope.core.agui.runtime.AguiRuntimeContextResolver;
+import io.agentscope.core.agui.store.AguiSnapshotHydrator;
+import io.agentscope.core.agui.store.AguiSnapshotStore;
+import io.agentscope.core.agui.store.AguiThreadSnapshot;
+import io.agentscope.core.agui.store.SnapshotRecordingEnricher;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -72,6 +76,8 @@ public class AguiRequestProcessor {
private final AguiAgentAdapterFactory adapterFactory;
private final AguiResumeCoordinator resumeCoordinator;
private final AguiRuntimeContextResolver runtimeContextResolver;
+ private final AguiSnapshotStore snapshotStore;
+ private final AguiSnapshotHydrator snapshotHydrator;
private AguiRequestProcessor(Builder builder) {
this.agentResolver =
@@ -83,6 +89,8 @@ private AguiRequestProcessor(Builder builder) {
: AguiAgentAdapterFactory.defaultFactory();
this.resumeCoordinator = new AguiResumeCoordinator();
this.runtimeContextResolver = builder.runtimeContextResolver;
+ this.snapshotStore = builder.snapshotStore;
+ this.snapshotHydrator = new AguiSnapshotHydrator();
}
/**
@@ -162,6 +170,12 @@ public ProcessResult process(AguiRuntimeContextRequest> request) {
config.isEmitRunFinishedAfterError()));
}
+ // A new run starts: drop any trailing interrupt from a prior run so a
+ // resolved historical interrupt can never reappear on reconnect.
+ if (snapshotStore != null) {
+ snapshotStore.clearPendingInterrupts(threadId);
+ }
+
try {
// Determine effective input based on server-side memory
RunAgentInput effectiveInput = input;
@@ -197,17 +211,59 @@ public ProcessResult process(AguiRuntimeContextRequest> request) {
runErrorSeen.get());
})
.doFinally(
- signalType ->
- resumeCoordinator.finishRun(
- threadId, runId));
+ signalType -> {
+ resumeCoordinator.finishRun(threadId, runId);
+ // Safety net for RUN_ERROR paths that bypass
+ // the enricher (e.g. adapter-produced errors).
+ flushSnapshotRecorder(threadId, runId);
+ });
} catch (Throwable error) {
resumeCoordinator.finishRun(threadId, runId);
+ flushSnapshotRecorder(threadId, runId);
return processorErrorEvents(input, error);
}
});
return new ProcessResult(agent, events, runtimeContext);
}
+ /**
+ * Rehydrate the visible conversation for a thread from the presentation snapshot store.
+ *
+ * Strictly read-only: resolves {@code threadId} / {@code runId} from the request input,
+ * looks up the stored snapshot, and delegates to {@link AguiSnapshotHydrator}. No agent is
+ * resolved, the resume coordinator is not mutated, and no adapter is created. When no store
+ * is configured (or the thread has no history) the minimal three-frame handshake is returned.
+ *
+ * @param request the AG-UI request context carrying input
+ * @return a flux of read-only hydrate frames
+ */
+ public Flux hydrate(AguiRuntimeContextRequest> request) {
+ Objects.requireNonNull(request, "request cannot be null");
+ RunAgentInput input = request.getInput();
+ String threadId = input != null ? input.getThreadId() : null;
+ String runId = input != null ? input.getRunId() : null;
+ if (threadId == null) {
+ threadId = "unknown";
+ }
+ if (runId == null) {
+ runId = "unknown";
+ }
+ AguiThreadSnapshot snapshot =
+ snapshotStore != null ? snapshotStore.find(threadId).orElse(null) : null;
+ return Flux.fromIterable(snapshotHydrator.hydrate(snapshot, threadId, runId));
+ }
+
+ private void flushSnapshotRecorder(String threadId, String runId) {
+ SnapshotRecordingEnricher recorder = snapshotRecorder();
+ if (recorder != null) {
+ recorder.flush(threadId, runId);
+ }
+ }
+
+ private SnapshotRecordingEnricher snapshotRecorder() {
+ return config != null ? config.getSnapshotRecorder() : null;
+ }
+
private Flux processorErrorEvents(RunAgentInput input, Throwable error) {
String errorMessage =
error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
@@ -363,6 +419,7 @@ public static class Builder {
private AguiAdapterConfig config;
private AguiAgentAdapterFactory adapterFactory;
private AguiRuntimeContextResolver runtimeContextResolver;
+ private AguiSnapshotStore snapshotStore;
/**
* Set the agent resolver.
@@ -409,6 +466,19 @@ public Builder runtimeContextResolver(AguiRuntimeContextResolver runtimeContextR
return this;
}
+ /**
+ * Set the presentation snapshot store used to hydrate reconnects and clear trailing
+ * interrupts. Optional; when null, {@link #hydrate} returns the empty handshake and no
+ * snapshots are recorded.
+ *
+ * @param snapshotStore the snapshot store, or null
+ * @return This builder
+ */
+ public Builder snapshotStore(AguiSnapshotStore snapshotStore) {
+ this.snapshotStore = snapshotStore;
+ return this;
+ }
+
/**
* Build the processor.
*
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiJsonPatch.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiJsonPatch.java
new file mode 100644
index 0000000000..7db6de6c5d
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiJsonPatch.java
@@ -0,0 +1,250 @@
+/*
+ * 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.store;
+
+import io.agentscope.core.agui.event.AguiEvent.JsonPatchOperation;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Minimal RFC 6902 patch applier for the AG-UI presentation snapshot store.
+ *
+ * Supports {@code add} / {@code replace} / {@code remove}; unknown operations are ignored and
+ * logged at debug. JSON Pointer tokens are split on {@code /}, unescaping {@code ~1} to {@code /}
+ * and then {@code ~0} to {@code ~} (order matters). Nested {@link Map} values are traversed;
+ * {@link List} index segments and the {@code -} append token are supported.
+ *
+ *
This is the inverse of {@link io.agentscope.core.agui.converter.AguiJsonDiff}, which emits
+ * exactly {@code add} / {@code remove} / {@code replace} with the same escaping, so a delta
+ * computed from one state always applies cleanly onto a matching state.
+ */
+final class AguiJsonPatch {
+
+ private static final Logger logger = LoggerFactory.getLogger(AguiJsonPatch.class);
+
+ private AguiJsonPatch() {}
+
+ /**
+ * Apply a list of patch operations to a (defensively copied) state map.
+ *
+ * @param target the state to patch, may be null
+ * @param ops the patch operations
+ * @return a new map with the operations applied; never mutates the input
+ */
+ static Map apply(Map target, List ops) {
+ Map result = deepCopyMap(target);
+ if (ops == null || ops.isEmpty()) {
+ return result;
+ }
+ for (JsonPatchOperation op : ops) {
+ switch (op.op()) {
+ case "add" -> applyAdd(result, op.path(), op.value());
+ case "replace" -> applyReplace(result, op.path(), op.value());
+ case "remove" -> applyRemove(result, op.path());
+ default ->
+ logger.debug(
+ "Ignoring unknown JSON Patch op '{}' at {}", op.op(), op.path());
+ }
+ }
+ return result;
+ }
+
+ private static void applyAdd(Map root, String pointer, Object value) {
+ List tokens = parsePointer(pointer);
+ if (tokens.isEmpty()) {
+ return;
+ }
+ Object parent = navigateParent(root, tokens);
+ if (parent == null) {
+ logger.debug("Cannot add to missing parent at {}", pointer);
+ return;
+ }
+ String last = tokens.get(tokens.size() - 1);
+ Object coerced = deepCopyValue(value);
+ if (parent instanceof Map) {
+ asMap(parent).put(last, coerced);
+ } else if (parent instanceof List) {
+ List list = asList(parent);
+ if ("-".equals(last)) {
+ list.add(coerced);
+ } else {
+ int index = parseIndex(last);
+ if (index < 0) {
+ logger.debug("Cannot add to non-numeric list index '{}' at {}", last, pointer);
+ return;
+ }
+ if (index >= list.size()) {
+ list.add(coerced);
+ } else {
+ list.add(index, coerced);
+ }
+ }
+ }
+ }
+
+ private static void applyReplace(Map root, String pointer, Object value) {
+ List tokens = parsePointer(pointer);
+ if (tokens.isEmpty()) {
+ return;
+ }
+ Object parent = navigateParent(root, tokens);
+ if (parent == null) {
+ logger.debug("Cannot replace missing parent at {}", pointer);
+ return;
+ }
+ String last = tokens.get(tokens.size() - 1);
+ Object coerced = deepCopyValue(value);
+ if (parent instanceof Map) {
+ if (asMap(parent).containsKey(last)) {
+ asMap(parent).put(last, coerced);
+ } else {
+ logger.debug("Cannot replace missing key '{}' at {}", last, pointer);
+ }
+ } else if (parent instanceof List) {
+ List list = asList(parent);
+ int index = parseIndex(last);
+ if (index < 0 || index >= list.size()) {
+ logger.debug("Cannot replace list index '{}' at {}", last, pointer);
+ return;
+ }
+ list.set(index, coerced);
+ }
+ }
+
+ private static void applyRemove(Map root, String pointer) {
+ List tokens = parsePointer(pointer);
+ if (tokens.isEmpty()) {
+ return;
+ }
+ Object parent = navigateParent(root, tokens);
+ if (parent == null) {
+ logger.debug("Cannot remove from missing parent at {}", pointer);
+ return;
+ }
+ String last = tokens.get(tokens.size() - 1);
+ if (parent instanceof Map) {
+ asMap(parent).remove(last);
+ } else if (parent instanceof List) {
+ List list = asList(parent);
+ int index = parseIndex(last);
+ if (index >= 0 && index < list.size()) {
+ list.remove(index);
+ }
+ }
+ }
+
+ private static Object navigateParent(Object root, List tokens) {
+ Object current = root;
+ for (int i = 0; i < tokens.size() - 1; i++) {
+ current = descend(current, tokens.get(i));
+ if (current == null) {
+ return null;
+ }
+ }
+ return current;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Object descend(Object current, String token) {
+ if (current instanceof Map) {
+ return ((Map) current).get(token);
+ }
+ if (current instanceof List) {
+ List list = (List) current;
+ int index = parseIndex(token);
+ if (index >= 0 && index < list.size()) {
+ return list.get(index);
+ }
+ }
+ return null;
+ }
+
+ private static List parsePointer(String pointer) {
+ if (pointer == null || pointer.isEmpty()) {
+ return List.of();
+ }
+ if (!pointer.startsWith("/")) {
+ return List.of(unescape(pointer));
+ }
+ String body = pointer.substring(1);
+ if (body.isEmpty()) {
+ return List.of();
+ }
+ String[] parts = body.split("/", -1);
+ List tokens = new ArrayList<>(parts.length);
+ for (String part : parts) {
+ tokens.add(unescape(part));
+ }
+ return tokens;
+ }
+
+ /** Unescape a JSON Pointer reference token: {@code ~1} to {@code /} then {@code ~0} to {@code ~}. */
+ private static String unescape(String token) {
+ return token.replace("~1", "/").replace("~0", "~");
+ }
+
+ private static int parseIndex(String token) {
+ if (token == null || token.isEmpty()) {
+ return -1;
+ }
+ try {
+ return Integer.parseInt(token);
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map asMap(Object value) {
+ return (Map) value;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List asList(Object value) {
+ return (List) value;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map deepCopyMap(Map source) {
+ Map copy = new LinkedHashMap<>();
+ if (source == null) {
+ return copy;
+ }
+ for (Map.Entry entry : source.entrySet()) {
+ copy.put(entry.getKey(), deepCopyValue(entry.getValue()));
+ }
+ return copy;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Object deepCopyValue(Object value) {
+ if (value instanceof Map) {
+ return deepCopyMap((Map) value);
+ }
+ if (value instanceof List) {
+ List copy = new ArrayList<>(((List>) value).size());
+ for (Object element : (List>) value) {
+ copy.add(deepCopyValue(element));
+ }
+ return copy;
+ }
+ return value;
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiSnapshotAccumulator.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiSnapshotAccumulator.java
new file mode 100644
index 0000000000..88e73ed567
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiSnapshotAccumulator.java
@@ -0,0 +1,414 @@
+/*
+ * 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.store;
+
+import io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.agui.event.AguiEvent.JsonPatchOperation;
+import io.agentscope.core.agui.model.AguiFunctionCall;
+import io.agentscope.core.agui.model.AguiMessage;
+import io.agentscope.core.agui.model.AguiToolCall;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Per-{@code threadId:runId} mutable folder that consumes outbound {@link AguiEvent}s and produces
+ * a materialized {@link AguiThreadSnapshot}.
+ *
+ * Seeded from {@link AguiSnapshotStore#find(String)} so messages / state / activities from
+ * earlier runs survive. This is where the former CopilotKit replay workarounds
+ * (resolved-interrupt suppression and dangling-tool-call synthesis) are absorbed: the snapshot
+ * only ever retains the trailing unresolved interrupt, and dangling tool calls are closed with a
+ * synthetic empty result so the browser never renders a stuck spinner — except for the tool that
+ * belongs to an open interrupt, which is left pending.
+ */
+final class AguiSnapshotAccumulator {
+
+ private final String threadId;
+ private final String runId;
+
+ /** Ordered message transcript, keyed by message id so upserts preserve position. */
+ private final LinkedHashMap messages = new LinkedHashMap<>();
+
+ /** Folded AG-UI state. */
+ private Map state = new LinkedHashMap<>();
+
+ /** Activity frames keyed by (messageId \0 activityType), insertion-ordered. */
+ private final LinkedHashMap activities =
+ new LinkedHashMap<>();
+
+ /** Assistant message builders keyed by message id, for text/tool-call folding. */
+ private final LinkedHashMap assistantBuilders = new LinkedHashMap<>();
+
+ /** Tool call frames keyed by tool call id. */
+ private final LinkedHashMap toolCallFrames = new LinkedHashMap<>();
+
+ /** Message id of the assistant turn currently receiving tool calls. */
+ private String currentAssistantMessageId;
+
+ private AguiEvent.RunFinishedOutcome pendingOutcome;
+
+ AguiSnapshotAccumulator(String threadId, String runId, AguiThreadSnapshot seed) {
+ this.threadId = threadId;
+ this.runId = runId;
+ if (seed != null) {
+ for (AguiMessage message : seed.messages()) {
+ if (message != null && message.getId() != null) {
+ messages.put(message.getId(), message);
+ }
+ }
+ state.putAll(seed.state());
+ for (AguiThreadSnapshot.ActivityFrame frame : seed.activities()) {
+ activities.put(activityKey(frame.messageId(), frame.activityType()), frame);
+ }
+ }
+ }
+
+ /**
+ * Consume one outbound AG-UI event and fold it into the accumulator.
+ *
+ * @param event the event to consume
+ */
+ void consume(AguiEvent event) {
+ if (event == null) {
+ return;
+ }
+ if (event instanceof AguiEvent.RunStarted runStarted) {
+ captureInputMessages(runStarted);
+ } else if (event instanceof AguiEvent.TextMessageStart start) {
+ startAssistantText(start.messageId(), start.role());
+ } else if (event instanceof AguiEvent.TextMessageContent content) {
+ appendAssistantText(content.messageId(), content.delta());
+ } else if (event instanceof AguiEvent.TextMessageEnd end) {
+ endAssistantText(end.messageId());
+ } else if (event instanceof AguiEvent.TextMessageChunk chunk) {
+ handleTextMessageChunk(chunk);
+ } else if (event instanceof AguiEvent.ToolCallStart toolCallStart) {
+ startToolCall(toolCallStart.toolCallId(), toolCallStart.toolCallName(), null);
+ } else if (event instanceof AguiEvent.ToolCallArgs args) {
+ appendToolCallArgs(args.toolCallId(), args.delta());
+ } else if (event instanceof AguiEvent.ToolCallChunk toolCallChunk) {
+ handleToolCallChunk(toolCallChunk);
+ } else if (event instanceof AguiEvent.ToolCallResult result) {
+ recordToolCallResult(result);
+ } else if (event instanceof AguiEvent.StateSnapshot stateSnapshot) {
+ state = new LinkedHashMap<>(stateSnapshot.snapshot());
+ } else if (event instanceof AguiEvent.StateDelta stateDelta) {
+ state = AguiJsonPatch.apply(state, stateDelta.delta());
+ } else if (event instanceof AguiEvent.ActivitySnapshot activitySnapshot) {
+ applyActivitySnapshot(activitySnapshot);
+ } else if (event instanceof AguiEvent.ActivityDelta activityDelta) {
+ applyActivityDelta(activityDelta);
+ } else if (event instanceof AguiEvent.RunFinished runFinished) {
+ if (runFinished.outcome() instanceof AguiEvent.RunFinishedInterruptOutcome interrupt) {
+ pendingOutcome = interrupt;
+ } else {
+ pendingOutcome = null;
+ }
+ } else if (event instanceof AguiEvent.RunError) {
+ pendingOutcome = null;
+ }
+ }
+
+ /**
+ * Materialize the accumulated state into an immutable snapshot.
+ *
+ * This is a read-only view of the current accumulated state: it builds a fresh
+ * transcript without mutating the accumulator, so it is safe to call repeatedly and even after
+ * additional events have been consumed.
+ *
+ * @return the materialized snapshot
+ */
+ AguiThreadSnapshot materialize() {
+ // 1. Assistant messages carry accumulated tool calls. Work on a fresh copy so the live
+ // transcript stays untouched and materialize stays idempotent.
+ LinkedHashMap transcript = new LinkedHashMap<>(messages);
+ for (AssistantBuilder builder : assistantBuilders.values()) {
+ List calls =
+ builder.toolCallIds.stream()
+ .map(toolCallFrames::get)
+ .filter(java.util.Objects::nonNull)
+ .map(
+ frame ->
+ new AguiToolCall(
+ frame.toolCallId,
+ new AguiFunctionCall(
+ frame.name != null
+ ? frame.name
+ : "unknown",
+ frame.args.toString())))
+ .toList();
+ transcript.put(
+ builder.messageId,
+ AguiMessage.textMessage(
+ builder.messageId, builder.role, builder.text.toString(), calls, null));
+ }
+
+ // 2. Resolved tool calls already appended their tool result messages (on result).
+
+ // 3. Dangling tool calls get a synthetic empty result, unless the tool belongs to an open
+ // interrupt (then it genuinely still awaits the user and is left open).
+ Set openToolCallIds = openInterruptToolCallIds();
+ for (ToolCallFrame frame : toolCallFrames.values()) {
+ if (frame.resolved) {
+ continue;
+ }
+ if (openToolCallIds.contains(frame.toolCallId)) {
+ continue;
+ }
+ String resultId = syntheticResultId(frame.toolCallId);
+ transcript.putIfAbsent(
+ resultId, AguiMessage.toolMessage(resultId, frame.toolCallId, null));
+ }
+
+ return new AguiThreadSnapshot(
+ threadId,
+ List.copyOf(transcript.values()),
+ state,
+ List.copyOf(activities.values()),
+ pendingOutcome,
+ runId,
+ System.currentTimeMillis());
+ }
+
+ private void captureInputMessages(AguiEvent.RunStarted runStarted) {
+ AguiEvent.RunStarted start = runStarted;
+ if (start.input() == null || start.input().getMessages() == null) {
+ return;
+ }
+ for (AguiMessage message : start.input().getMessages()) {
+ if (message == null || message.getId() == null) {
+ continue;
+ }
+ messages.putIfAbsent(message.getId(), message);
+ }
+ }
+
+ private void startAssistantText(String messageId, String role) {
+ if (messageId == null) {
+ return;
+ }
+ currentAssistantMessageId = messageId;
+ AssistantBuilder builder =
+ assistantBuilders.computeIfAbsent(
+ messageId, ignored -> new AssistantBuilder(messageId));
+ builder.role = role != null ? role : "assistant";
+ // Ensure a placeholder exists in the transcript so ordering is preserved.
+ messages.putIfAbsent(
+ messageId, AguiMessage.textMessage(messageId, builder.role, null, List.of(), null));
+ }
+
+ private void appendAssistantText(String messageId, String delta) {
+ if (messageId == null || delta == null) {
+ return;
+ }
+ currentAssistantMessageId = messageId;
+ AssistantBuilder builder =
+ assistantBuilders.computeIfAbsent(
+ messageId, ignored -> new AssistantBuilder(messageId));
+ builder.text.append(delta);
+ messages.put(
+ messageId,
+ AguiMessage.textMessage(
+ messageId, builder.role, builder.text.toString(), List.of(), null));
+ }
+
+ private void endAssistantText(String messageId) {
+ if (messageId == null) {
+ return;
+ }
+ AssistantBuilder builder = assistantBuilders.get(messageId);
+ if (builder != null) {
+ messages.put(
+ messageId,
+ AguiMessage.textMessage(
+ messageId, builder.role, builder.text.toString(), List.of(), null));
+ }
+ currentAssistantMessageId = messageId;
+ }
+
+ private void handleTextMessageChunk(AguiEvent.TextMessageChunk chunk) {
+ if (chunk.messageId() == null) {
+ return;
+ }
+ startAssistantText(chunk.messageId(), chunk.role() != null ? chunk.role() : "assistant");
+ if (chunk.delta() != null) {
+ appendAssistantText(chunk.messageId(), chunk.delta());
+ }
+ }
+
+ private void startToolCall(String toolCallId, String toolCallName, String parentMessageId) {
+ if (toolCallId == null) {
+ return;
+ }
+ ToolCallFrame frame =
+ toolCallFrames.computeIfAbsent(
+ toolCallId, ignored -> new ToolCallFrame(toolCallId));
+ if (toolCallName != null && !toolCallName.isBlank()) {
+ frame.name = toolCallName;
+ }
+ linkToolCallToAssistant(frame, parentMessageId);
+ }
+
+ private void appendToolCallArgs(String toolCallId, String delta) {
+ if (toolCallId == null || delta == null) {
+ return;
+ }
+ ToolCallFrame frame = toolCallFrames.get(toolCallId);
+ if (frame != null) {
+ frame.args.append(delta);
+ }
+ }
+
+ private void handleToolCallChunk(AguiEvent.ToolCallChunk chunk) {
+ if (chunk.toolCallId() == null) {
+ return;
+ }
+ // Chunk mode carries an explicit parentMessageId, so prefer it over the heuristic.
+ startToolCall(chunk.toolCallId(), chunk.toolCallName(), chunk.parentMessageId());
+ if (chunk.delta() != null) {
+ appendToolCallArgs(chunk.toolCallId(), chunk.delta());
+ }
+ }
+
+ private void recordToolCallResult(AguiEvent.ToolCallResult result) {
+ ToolCallFrame frame = toolCallFrames.get(result.toolCallId());
+ if (frame == null) {
+ frame = new ToolCallFrame(result.toolCallId());
+ toolCallFrames.put(result.toolCallId(), frame);
+ }
+ frame.resultContent = result.content();
+ frame.resultMessageId = result.messageId();
+ frame.resolved = true;
+ String resultId =
+ result.messageId() != null
+ ? result.messageId()
+ : syntheticResultId(result.toolCallId());
+ messages.putIfAbsent(
+ resultId, AguiMessage.toolMessage(resultId, result.toolCallId(), result.content()));
+ }
+
+ private void linkToolCallToAssistant(ToolCallFrame frame, String parentMessageId) {
+ // Prefer an explicit parent (chunk mode) over the heuristic last-text-message owner.
+ String owner = parentMessageId != null ? parentMessageId : currentAssistantMessageId;
+ if (owner == null) {
+ owner = "assistant:" + UUID.randomUUID();
+ currentAssistantMessageId = owner;
+ }
+ AssistantBuilder builder = assistantBuilders.computeIfAbsent(owner, AssistantBuilder::new);
+ // Ensure a placeholder exists in the transcript so ordering is preserved even when the
+ // parent's TextMessageStart has not (or will not) arrive.
+ messages.putIfAbsent(
+ owner, AguiMessage.textMessage(owner, builder.role, null, List.of(), null));
+ builder.addToolCallId(frame.toolCallId);
+ }
+
+ private void applyActivitySnapshot(AguiEvent.ActivitySnapshot snapshot) {
+ String key = activityKey(snapshot.messageId(), snapshot.activityType());
+ boolean replace = snapshot.replace() == null || snapshot.replace();
+ if (replace) {
+ activities.put(
+ key,
+ new AguiThreadSnapshot.ActivityFrame(
+ snapshot.messageId(), snapshot.activityType(), snapshot.content()));
+ } else {
+ AguiThreadSnapshot.ActivityFrame existing = activities.get(key);
+ Map merged =
+ new LinkedHashMap<>(existing != null ? existing.content() : Map.of());
+ merged.putAll(snapshot.content());
+ activities.put(
+ key,
+ new AguiThreadSnapshot.ActivityFrame(
+ snapshot.messageId(), snapshot.activityType(), merged));
+ }
+ }
+
+ private void applyActivityDelta(AguiEvent.ActivityDelta delta) {
+ String key = activityKey(delta.messageId(), delta.activityType());
+ AguiThreadSnapshot.ActivityFrame existing = activities.get(key);
+ Map content =
+ new LinkedHashMap<>(existing != null ? existing.content() : Map.of());
+ List patch = delta.patch();
+ if (patch != null && !patch.isEmpty()) {
+ content = AguiJsonPatch.apply(content, patch);
+ }
+ activities.put(
+ key,
+ new AguiThreadSnapshot.ActivityFrame(
+ delta.messageId(), delta.activityType(), content));
+ }
+
+ private Set openInterruptToolCallIds() {
+ if (!(pendingOutcome instanceof AguiEvent.RunFinishedInterruptOutcome interrupt)) {
+ return Set.of();
+ }
+ List interrupts = interrupt.interrupts();
+ if (interrupts == null || interrupts.isEmpty()) {
+ return Set.of();
+ }
+ Set ids = new HashSet<>();
+ for (AguiEvent.Interrupt i : interrupts) {
+ if (i.toolCallId() != null) {
+ ids.add(i.toolCallId());
+ }
+ }
+ return ids;
+ }
+
+ private static String activityKey(String messageId, String activityType) {
+ return messageId + "\u0000" + activityType;
+ }
+
+ private static String syntheticResultId(String toolCallId) {
+ return toolCallId + ":result";
+ }
+
+ /** Mutable builder for an assistant message's text and linked tool calls. */
+ private static final class AssistantBuilder {
+ final String messageId;
+ String role = "assistant";
+ final StringBuilder text = new StringBuilder();
+ final List toolCallIds = new ArrayList<>();
+
+ AssistantBuilder(String messageId) {
+ this.messageId = messageId;
+ }
+
+ void addToolCallId(String toolCallId) {
+ if (!toolCallIds.contains(toolCallId)) {
+ toolCallIds.add(toolCallId);
+ }
+ }
+ }
+
+ /** Mutable holder for a tool call's streamed args and result. */
+ private static final class ToolCallFrame {
+ final String toolCallId;
+ String name;
+ final StringBuilder args = new StringBuilder();
+ String resultContent;
+ String resultMessageId;
+ boolean resolved;
+
+ ToolCallFrame(String toolCallId) {
+ this.toolCallId = toolCallId;
+ }
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiSnapshotHydrator.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiSnapshotHydrator.java
new file mode 100644
index 0000000000..278732cb36
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiSnapshotHydrator.java
@@ -0,0 +1,88 @@
+/*
+ * 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.store;
+
+import io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.agui.model.AguiMessage;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Replays a stored {@link AguiThreadSnapshot} as a read-only sequence of AG-UI frames.
+ *
+ * Frames are emitted in this exact order:
+ *
+ *
{@code
+ * RUN_STARTED(threadId, runId)
+ * MESSAGES_SNAPSHOT(messages)
+ * STATE_SNAPSHOT(state) // omitted when state is empty
+ * ACTIVITY_SNAPSHOT(...) per ActivityFrame // omitted when none
+ * RUN_FINISHED(result=null, outcome=pendingOutcome)
+ * }
+ *
+ * A null {@code pendingOutcome} serializes as a plain successful run — exactly the shape the
+ * former CopilotKit empty-handshake forced via post-processing. An empty or missing snapshot
+ * produces the minimal three-frame handshake ({@code RUN_STARTED} → {@code MESSAGES_SNAPSHOT([])}
+ * → {@code RUN_FINISHED}).
+ *
+ *
Hydration is strictly read-only: it never mutates the snapshot store, the agent state store,
+ * or the resume coordinator.
+ */
+public final class AguiSnapshotHydrator {
+
+ /** Create a new stateless hydrator. */
+ public AguiSnapshotHydrator() {}
+
+ /**
+ * Build the hydrate frame sequence for a snapshot.
+ *
+ * @param snapshot the stored snapshot, or null when the thread has no history
+ * @param threadId the thread id for the emitted frames
+ * @param runId the run id for the emitted frames
+ * @return the ordered AG-UI frames
+ */
+ public List hydrate(AguiThreadSnapshot snapshot, String threadId, String runId) {
+ List events = new ArrayList<>();
+ events.add(new AguiEvent.RunStarted(threadId, runId));
+
+ List messages =
+ snapshot != null && snapshot.messages() != null ? snapshot.messages() : List.of();
+ events.add(new AguiEvent.MessagesSnapshot(threadId, runId, messages));
+
+ Map state = snapshot != null ? snapshot.state() : Map.of();
+ if (state != null && !state.isEmpty()) {
+ events.add(new AguiEvent.StateSnapshot(threadId, runId, state));
+ }
+
+ if (snapshot != null && snapshot.activities() != null) {
+ for (AguiThreadSnapshot.ActivityFrame frame : snapshot.activities()) {
+ events.add(
+ new AguiEvent.ActivitySnapshot(
+ threadId,
+ runId,
+ frame.messageId(),
+ frame.activityType(),
+ frame.content(),
+ true));
+ }
+ }
+
+ AguiEvent.RunFinishedOutcome outcome = snapshot != null ? snapshot.pendingOutcome() : null;
+ events.add(new AguiEvent.RunFinished(threadId, runId, null, outcome));
+ return events;
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiSnapshotStore.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiSnapshotStore.java
new file mode 100644
index 0000000000..bacdb56cdc
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiSnapshotStore.java
@@ -0,0 +1,72 @@
+/*
+ * 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.store;
+
+import java.util.Optional;
+
+/**
+ * Presentation-state store for AG-UI threads.
+ *
+ * Holds the derived presentation state (materialized messages / state / activity) so a
+ * reconnecting client can rebuild the visible conversation without re-running the agent. This is
+ * presentation-only data: it is safe to lose, and it is not a source of truth for
+ * human-in-the-loop interrupts. Authoritative agent state and the live HITL contract are owned by
+ * the agent state store and the resume coordinator; hydrate is strictly read-only and never
+ * mutates either.
+ *
+ *
Because the store only ever retains the trailing unresolved interrupt, a resolved
+ * historical interrupt cannot be revived on reconnect — that failure mode is removed at the data
+ * model rather than filtered after the fact.
+ */
+public interface AguiSnapshotStore {
+
+ /**
+ * Persist a materialized snapshot for a thread.
+ *
+ * @param snapshot the snapshot to store
+ */
+ void save(AguiThreadSnapshot snapshot);
+
+ /**
+ * Look up the snapshot for a thread.
+ *
+ * @param threadId the thread id
+ * @return the snapshot, or empty if none is stored
+ */
+ Optional find(String threadId);
+
+ /**
+ * Delete the snapshot for a thread.
+ *
+ * @param threadId the thread id
+ */
+ void delete(String threadId);
+
+ /**
+ * Drop the trailing interrupt outcome for a thread, if any.
+ *
+ * Called when a new run starts so a previously-unresolved interrupt cannot reappear on
+ * reconnect. Implementations that retain only the trailing interrupt can satisfy this with a
+ * read-modify-write via {@link AguiThreadSnapshot#withoutPendingOutcome()}.
+ *
+ * @param threadId the thread id
+ */
+ default void clearPendingInterrupts(String threadId) {
+ find(threadId)
+ .filter(snapshot -> snapshot.pendingOutcome() != null)
+ .ifPresent(snapshot -> save(snapshot.withoutPendingOutcome()));
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiThreadSnapshot.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiThreadSnapshot.java
new file mode 100644
index 0000000000..bdf2e7a959
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/AguiThreadSnapshot.java
@@ -0,0 +1,110 @@
+/*
+ * 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.store;
+
+import io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.agui.model.AguiMessage;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Immutable materialized presentation state for one AG-UI thread.
+ *
+ *
A snapshot captures what a reconnecting client should draw: the message transcript, the
+ * folded state, any activity frames, and the trailing run id. It is presentation-only :
+ * derived from the live event stream, safe to lose, and not a source of truth for human-in-the-loop
+ * contracts (the agent state store and resume coordinator own those).
+ *
+ * @param threadId the AG-UI thread id
+ * @param messages the materialized conversation messages
+ * @param state the folded AG-UI state
+ * @param activities the materialized activity frames
+ * @param pendingOutcome the trailing unresolved run outcome, or null when the run completed
+ * normally; only the trailing interrupt is ever retained
+ * @param lastRunId the run id that produced this snapshot
+ * @param updatedAt the wall-clock millis when the snapshot was materialized
+ */
+public record AguiThreadSnapshot(
+ String threadId,
+ List messages,
+ Map state,
+ List activities,
+ AguiEvent.RunFinishedOutcome pendingOutcome,
+ String lastRunId,
+ long updatedAt) {
+
+ /**
+ * One materialized activity frame, keyed by {@code (messageId, activityType)}.
+ *
+ * @param messageId the message id the activity is attached to
+ * @param activityType the activity type
+ * @param content the activity content
+ */
+ public record ActivityFrame(
+ String messageId, String activityType, Map content) {
+ public ActivityFrame {
+ content =
+ content != null
+ ? Collections.unmodifiableMap(new LinkedHashMap<>(content))
+ : Collections.emptyMap();
+ }
+ }
+
+ /**
+ * Canonical constructor with defensive copies, matching the style of
+ * {@link AguiEvent.MessagesSnapshot}.
+ */
+ public AguiThreadSnapshot {
+ messages =
+ messages != null
+ ? Collections.unmodifiableList(new ArrayList<>(messages))
+ : Collections.emptyList();
+ state =
+ state != null
+ ? Collections.unmodifiableMap(new LinkedHashMap<>(state))
+ : Collections.emptyMap();
+ activities =
+ activities != null
+ ? Collections.unmodifiableList(new ArrayList<>(activities))
+ : Collections.emptyList();
+ }
+
+ /**
+ * Create an empty snapshot for a thread.
+ *
+ * @param threadId the thread id
+ * @return an empty snapshot with no messages, state, activities or pending outcome
+ */
+ public static AguiThreadSnapshot empty(String threadId) {
+ return new AguiThreadSnapshot(threadId, List.of(), Map.of(), List.of(), null, null, 0L);
+ }
+
+ /**
+ * Return a copy of this snapshot with the trailing interrupt outcome cleared.
+ *
+ * Used when a new run starts so a previously-unresolved interrupt cannot reappear on
+ * reconnect.
+ *
+ * @return a snapshot with a null pending outcome
+ */
+ public AguiThreadSnapshot withoutPendingOutcome() {
+ return new AguiThreadSnapshot(
+ threadId, messages, state, activities, null, lastRunId, updatedAt);
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/InMemoryAguiSnapshotStore.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/InMemoryAguiSnapshotStore.java
new file mode 100644
index 0000000000..0488708070
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/InMemoryAguiSnapshotStore.java
@@ -0,0 +1,90 @@
+/*
+ * 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.store;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * In-memory {@link AguiSnapshotStore} backed by a {@link ConcurrentHashMap}.
+ *
+ *
Retains at most {@code maxThreads} snapshots; when the limit is exceeded the snapshot with the
+ * oldest {@code updatedAt} is evicted in a single pass (no external dependencies).
+ */
+public final class InMemoryAguiSnapshotStore implements AguiSnapshotStore {
+
+ private final int maxThreads;
+ private final ConcurrentMap snapshots = new ConcurrentHashMap<>();
+
+ /** Create a store with the default capacity of 1000 threads. */
+ public InMemoryAguiSnapshotStore() {
+ this(1000);
+ }
+
+ /**
+ * Create a store with a fixed capacity.
+ *
+ * @param maxThreads the maximum number of threads to retain
+ */
+ public InMemoryAguiSnapshotStore(int maxThreads) {
+ if (maxThreads <= 0) {
+ throw new IllegalArgumentException("maxThreads must be positive");
+ }
+ this.maxThreads = maxThreads;
+ }
+
+ @Override
+ public void save(AguiThreadSnapshot snapshot) {
+ if (snapshot == null || snapshot.threadId() == null) {
+ return;
+ }
+ snapshots.put(snapshot.threadId(), snapshot);
+ evictIfOverCapacity();
+ }
+
+ @Override
+ public Optional find(String threadId) {
+ if (threadId == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(snapshots.get(threadId));
+ }
+
+ @Override
+ public void delete(String threadId) {
+ if (threadId != null) {
+ snapshots.remove(threadId);
+ }
+ }
+
+ private void evictIfOverCapacity() {
+ while (snapshots.size() > maxThreads) {
+ Map.Entry oldest = null;
+ for (Map.Entry entry : snapshots.entrySet()) {
+ if (oldest == null
+ || entry.getValue().updatedAt() < oldest.getValue().updatedAt()) {
+ oldest = entry;
+ }
+ }
+ if (oldest == null) {
+ break;
+ }
+ snapshots.remove(oldest.getKey(), oldest.getValue());
+ }
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/SnapshotRecordingEnricher.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/SnapshotRecordingEnricher.java
new file mode 100644
index 0000000000..dc46de4f34
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/store/SnapshotRecordingEnricher.java
@@ -0,0 +1,116 @@
+/*
+ * 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.store;
+
+import io.agentscope.core.agui.adapter.strategy.AguiEventEnricher;
+import io.agentscope.core.agui.adapter.strategy.AguiStreamContext;
+import io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.event.AgentEvent;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Records outbound AG-UI events into an {@link AguiSnapshotStore}.
+ *
+ * Appended last in the enricher chain so it observes fully enriched frames. Feeds every
+ * frame to a per-{@code threadId:runId} {@link AguiSnapshotAccumulator}; on a terminal
+ * {@link AguiEvent.RunFinished} / {@link AguiEvent.RunError} it materializes and persists the
+ * snapshot, then drops the accumulator. Because the enricher runs on both the
+ * {@code AgentEventConverterRegistry.convert()} path and the framework
+ * {@code enrich(null, finishPendingEvents, ctx)} path, it sees frames from all converters.
+ *
+ *
{@link #flush(String, String)} is a safety net for streams that terminate without emitting a
+ * terminal frame (e.g. {@code RUN_ERROR} produced directly by the adapter, which bypasses the
+ * enricher).
+ */
+public final class SnapshotRecordingEnricher implements AguiEventEnricher {
+
+ private final AguiSnapshotStore store;
+ private final ConcurrentMap accumulators =
+ new ConcurrentHashMap<>();
+
+ /** Create a recording enricher backed by the given store. */
+ public SnapshotRecordingEnricher(AguiSnapshotStore store) {
+ this.store = store;
+ }
+
+ /** The store this enricher records into. */
+ public AguiSnapshotStore getStore() {
+ return store;
+ }
+
+ @Override
+ public List enrich(
+ AgentEvent source, List events, AguiStreamContext context) {
+ if (store == null || context == null || events == null || events.isEmpty()) {
+ return events;
+ }
+ String threadId = context.getThreadId();
+ String runId = context.getRunId();
+ if (threadId == null || runId == null) {
+ return events;
+ }
+ String key = accumulatorKey(threadId, runId);
+ AguiSnapshotAccumulator accumulator =
+ accumulators.computeIfAbsent(
+ key,
+ ignored ->
+ new AguiSnapshotAccumulator(
+ threadId, runId, store.find(threadId).orElse(null)));
+ for (AguiEvent event : events) {
+ accumulator.consume(event);
+ }
+ if (isTerminal(events)) {
+ store.save(accumulator.materialize());
+ accumulators.remove(key);
+ }
+ return events;
+ }
+
+ /**
+ * Flush and persist the accumulator for a run, if one is still in flight.
+ *
+ * Called as a safety net when a stream terminates without a terminal frame reaching the
+ * enricher (e.g. an adapter-produced {@code RUN_ERROR}). Safe to call after a normal
+ * terminal frame: the accumulator will already have been removed and this is a no-op.
+ *
+ * @param threadId the thread id
+ * @param runId the run id
+ */
+ public void flush(String threadId, String runId) {
+ if (store == null || threadId == null || runId == null) {
+ return;
+ }
+ AguiSnapshotAccumulator accumulator = accumulators.remove(accumulatorKey(threadId, runId));
+ if (accumulator != null) {
+ store.save(accumulator.materialize());
+ }
+ }
+
+ private static boolean isTerminal(List events) {
+ for (AguiEvent event : events) {
+ if (event instanceof AguiEvent.RunFinished || event instanceof AguiEvent.RunError) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String accumulatorKey(String threadId, String runId) {
+ return threadId + ":" + runId;
+ }
+}
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..345e9e9190 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
@@ -26,6 +26,9 @@
import io.agentscope.core.agui.adapter.strategy.AguiEventEnricher;
import io.agentscope.core.agui.adapter.strategy.BaseEventPropertiesEnricher;
import io.agentscope.core.agui.model.ToolMergeMode;
+import io.agentscope.core.agui.store.AguiSnapshotStore;
+import io.agentscope.core.agui.store.InMemoryAguiSnapshotStore;
+import io.agentscope.core.agui.store.SnapshotRecordingEnricher;
import io.agentscope.core.event.AgentEvent;
import java.time.Duration;
import java.util.Collections;
@@ -325,6 +328,49 @@ void testBaseEventPropertiesEnricherDisabledByDefault() {
assertTrue(config.getEventEnrichers().isEmpty());
}
+ @Test
+ void testSnapshotStoreDisabledByDefault() {
+ AguiAdapterConfig config = AguiAdapterConfig.builder().build();
+
+ assertFalse(config.isSnapshotStoreEnabled());
+ assertNull(config.getSnapshotStore());
+ // No SnapshotRecordingEnricher in the chain by default.
+ assertTrue(
+ config.getEventEnrichers().stream()
+ .noneMatch(e -> e instanceof SnapshotRecordingEnricher));
+ }
+
+ @Test
+ void testSnapshotRecordingEnricherAppendedLastWhenEnabled() {
+ AguiEventEnricher custom = (source, events, context) -> events;
+ AguiSnapshotStore store = new InMemoryAguiSnapshotStore();
+ AguiAdapterConfig config =
+ AguiAdapterConfig.builder()
+ .baseEventPropertiesEnricherEnabled(true)
+ .addEventEnricher(custom)
+ .snapshotStoreEnabled(true)
+ .snapshotStore(store)
+ .build();
+
+ assertTrue(config.isSnapshotStoreEnabled());
+ assertSame(store, config.getSnapshotStore());
+
+ // Base enricher, then custom, then the recorder last.
+ assertEquals(3, config.getEventEnrichers().size());
+ assertTrue(config.getEventEnrichers().get(0) instanceof BaseEventPropertiesEnricher);
+ assertSame(custom, config.getEventEnrichers().get(1));
+ assertTrue(config.getEventEnrichers().get(2) instanceof SnapshotRecordingEnricher);
+ }
+
+ @Test
+ void testSnapshotStoreEnabledWithoutStoreAddsNoEnricher() {
+ AguiAdapterConfig config = AguiAdapterConfig.builder().snapshotStoreEnabled(true).build();
+
+ assertTrue(config.isSnapshotStoreEnabled());
+ assertNull(config.getSnapshotStore());
+ assertTrue(config.getEventEnrichers().isEmpty());
+ }
+
private AgentEventConverter noopConverter() {
return new AgentEventConverter() {
@Override
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiActivityConverterTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiActivityConverterTest.java
new file mode 100644
index 0000000000..6dd683f2bf
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiActivityConverterTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.converter;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.agentscope.core.agui.event.AguiEvent;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link AguiActivityConverter}, mirroring {@link AguiStateConverterTest}.
+ */
+class AguiActivityConverterTest {
+
+ private AguiActivityConverter converter;
+
+ @BeforeEach
+ void setUp() {
+ converter = new AguiActivityConverter();
+ }
+
+ @Test
+ void testCreateSnapshot() {
+ Map content = Map.of("step", 1, "label", "running");
+
+ AguiEvent.ActivitySnapshot snapshot =
+ converter.createSnapshot("t1", "r1", "m1", "progress", content, true);
+
+ assertEquals("t1", snapshot.getThreadId());
+ assertEquals("r1", snapshot.getRunId());
+ assertEquals("m1", snapshot.messageId());
+ assertEquals("progress", snapshot.activityType());
+ assertEquals(1, snapshot.content().get("step"));
+ assertEquals(true, snapshot.replace());
+ }
+
+ @Test
+ void testCreateDeltaForAddedKey() {
+ AguiEvent.ActivityDelta delta =
+ converter.createDelta(
+ "t1", "r1", "m1", "progress", new HashMap<>(), Map.of("newKey", "v"));
+
+ assertNotNull(delta);
+ assertEquals(1, delta.patch().size());
+ assertEquals("add", delta.patch().get(0).op());
+ assertEquals("/newKey", delta.patch().get(0).path());
+ }
+
+ @Test
+ void testCreateDeltaForRemovedKey() {
+ AguiEvent.ActivityDelta delta =
+ converter.createDelta(
+ "t1", "r1", "m1", "progress", Map.of("oldKey", "v"), new HashMap<>());
+
+ assertNotNull(delta);
+ assertEquals(1, delta.patch().size());
+ assertEquals("remove", delta.patch().get(0).op());
+ }
+
+ @Test
+ void testCreateDeltaForReplacedValue() {
+ AguiEvent.ActivityDelta delta =
+ converter.createDelta(
+ "t1", "r1", "m1", "progress", Map.of("key", "old"), Map.of("key", "new"));
+
+ assertNotNull(delta);
+ assertEquals("replace", delta.patch().get(0).op());
+ assertEquals("new", delta.patch().get(0).value());
+ }
+
+ @Test
+ void testCreateDeltaReturnsNullForNoChanges() {
+ AguiEvent.ActivityDelta delta =
+ converter.createDelta(
+ "t1", "r1", "m1", "progress", Map.of("k", "v"), Map.of("k", "v"));
+
+ assertNull(delta);
+ }
+
+ @Test
+ void testHasChanges() {
+ assertTrue(converter.hasChanges(Map.of("k", "a"), Map.of("k", "b")));
+ assertFalse(converter.hasChanges(Map.of("k", "a"), Map.of("k", "a")));
+ assertFalse(converter.hasChanges(null, null));
+ }
+
+ @Test
+ void testCreateDeltaNested() {
+ Map nestedBefore = new HashMap<>();
+ nestedBefore.put("inner", "old");
+ Map before = new HashMap<>();
+ before.put("nested", nestedBefore);
+
+ Map nestedAfter = new HashMap<>();
+ nestedAfter.put("inner", "new");
+ Map after = new HashMap<>();
+ after.put("nested", nestedAfter);
+
+ AguiEvent.ActivityDelta delta =
+ converter.createDelta("t1", "r1", "m1", "progress", before, after);
+
+ assertNotNull(delta);
+ assertEquals(1, delta.patch().size());
+ assertEquals("/nested/inner", delta.patch().get(0).path());
+ assertEquals("replace", delta.patch().get(0).op());
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiRequestProcessorTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiRequestProcessorTest.java
index c4f127b260..83a7edb12a 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiRequestProcessorTest.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiRequestProcessorTest.java
@@ -40,7 +40,10 @@
import io.agentscope.core.agui.model.AguiResume;
import io.agentscope.core.agui.model.RunAgentInput;
import io.agentscope.core.agui.runtime.AguiRuntimeContextRequest;
+import io.agentscope.core.agui.store.AguiThreadSnapshot;
+import io.agentscope.core.agui.store.InMemoryAguiSnapshotStore;
import io.agentscope.core.event.AgentEndEvent;
+import io.agentscope.core.event.AgentStartEvent;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.ToolResultBlock;
import java.util.List;
@@ -729,6 +732,122 @@ public Flux run(RunAgentInput input, RuntimeContext runtimeContext) {
}
}
+ @Test
+ void hydrateWithNoStoreReturnsEmptyHandshake() {
+ AguiRequestProcessor processor =
+ AguiRequestProcessor.builder().agentResolver(mock(AgentResolver.class)).build();
+
+ List events = processor.hydrate(request(input("run-1"))).collectList().block();
+
+ assertNotNull(events);
+ assertEquals(3, events.size());
+ assertEquals(AguiEventType.RUN_STARTED, events.get(0).getType());
+ assertInstanceOf(AguiEvent.MessagesSnapshot.class, events.get(1));
+ assertEquals(AguiEventType.RUN_FINISHED, events.get(2).getType());
+ }
+
+ @Test
+ void hydrateWithSnapshotReplaysMessages() {
+ InMemoryAguiSnapshotStore store = new InMemoryAguiSnapshotStore();
+ store.save(
+ new AguiThreadSnapshot(
+ "thread-1",
+ List.of(AguiMessage.userMessage("u1", "hi")),
+ Map.of(),
+ List.of(),
+ null,
+ "run-0",
+ 1L));
+ AguiRequestProcessor processor =
+ AguiRequestProcessor.builder()
+ .agentResolver(mock(AgentResolver.class))
+ .snapshotStore(store)
+ .build();
+
+ List events = processor.hydrate(request(input("run-1"))).collectList().block();
+
+ assertNotNull(events);
+ AguiEvent.MessagesSnapshot snapshot =
+ assertInstanceOf(AguiEvent.MessagesSnapshot.class, events.get(1));
+ assertEquals(1, snapshot.messages().size());
+ assertEquals("u1", snapshot.messages().get(0).getId());
+ }
+
+ @Test
+ void beginRunClearsPendingInterruptFromSnapshotStore() {
+ InMemoryAguiSnapshotStore store = new InMemoryAguiSnapshotStore();
+ store.save(
+ new AguiThreadSnapshot(
+ "thread-1",
+ List.of(AguiMessage.userMessage("u1", "hi")),
+ Map.of(),
+ List.of(),
+ new AguiEvent.RunFinishedInterruptOutcome(List.of(interrupt("i1", "tc1"))),
+ "run-0",
+ 1L));
+ AgentResolver resolver = mock(AgentResolver.class);
+ ReActAgent agent = mock(ReActAgent.class);
+ when(resolver.resolveAgent(eq("default"), eq("thread-1"), nullable(String.class)))
+ .thenReturn(agent);
+ when(resolver.hasMemory(any(RuntimeContext.class))).thenReturn(false);
+ when(agent.streamEvents(anyList(), any(RuntimeContext.class)))
+ .thenReturn(Flux.just(new AgentEndEvent("ok")));
+ AguiRequestProcessor processor =
+ AguiRequestProcessor.builder()
+ .agentResolver(resolver)
+ .runtimeContextResolver(request -> null)
+ .snapshotStore(store)
+ .build();
+
+ processor.process(request(input("run-1"))).events().collectList().block();
+
+ AguiThreadSnapshot snapshot = store.find("thread-1").orElseThrow();
+ assertEquals(null, snapshot.pendingOutcome());
+ }
+
+ @Test
+ void processRecordsSnapshotReplayedByHydrate() {
+ InMemoryAguiSnapshotStore store = new InMemoryAguiSnapshotStore();
+ AguiAdapterConfig config =
+ AguiAdapterConfig.builder().snapshotStoreEnabled(true).snapshotStore(store).build();
+ AgentResolver resolver = mock(AgentResolver.class);
+ ReActAgent agent = mock(ReActAgent.class);
+ when(resolver.resolveAgent(eq("default"), eq("thread-1"), nullable(String.class)))
+ .thenReturn(agent);
+ when(resolver.hasMemory(any(RuntimeContext.class))).thenReturn(false);
+ when(agent.streamEvents(anyList(), any(RuntimeContext.class)))
+ .thenReturn(
+ Flux.just(
+ new AgentStartEvent("thread-1", "reply-1", "assistant"),
+ new AgentEndEvent("ok")));
+ AguiRequestProcessor processor =
+ AguiRequestProcessor.builder()
+ .agentResolver(resolver)
+ .runtimeContextResolver(request -> null)
+ .config(config)
+ .snapshotStore(store)
+ .build();
+
+ processor.process(request(input("run-1"))).events().collectList().block();
+
+ // The framework recorded a presentation snapshot for the thread.
+ AguiThreadSnapshot snapshot = store.find("thread-1").orElseThrow();
+ assertEquals("run-1", snapshot.lastRunId());
+ // The user turn from RUN_STARTED.input survived.
+ assertEquals(1, snapshot.messages().size());
+ assertEquals("msg-1", snapshot.messages().get(0).getId());
+
+ // Hydrate replays a read-only frame sequence from the recorded snapshot.
+ List frames = processor.hydrate(request(input("run-2"))).collectList().block();
+ assertNotNull(frames);
+ assertEquals(AguiEventType.RUN_STARTED, frames.get(0).getType());
+ assertInstanceOf(AguiEvent.MessagesSnapshot.class, frames.get(1));
+ AguiEvent.MessagesSnapshot messages = (AguiEvent.MessagesSnapshot) frames.get(1);
+ assertEquals(1, messages.messages().size());
+ assertEquals("msg-1", messages.messages().get(0).getId());
+ assertEquals(AguiEventType.RUN_FINISHED, frames.get(frames.size() - 1).getType());
+ }
+
private static void assertResumeContractErrorLifecycle(List events) {
assertEquals(
List.of(AguiEventType.RUN_STARTED, AguiEventType.RUN_ERROR),
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/AguiJsonPatchTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/AguiJsonPatchTest.java
new file mode 100644
index 0000000000..1b7efef0ec
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/AguiJsonPatchTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.store;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.agentscope.core.agui.event.AguiEvent.JsonPatchOperation;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for the package-visible {@link AguiJsonPatch} RFC 6902 applier.
+ */
+class AguiJsonPatchTest {
+
+ @Test
+ void addReplacesMissingKey() {
+ Map target = new java.util.LinkedHashMap<>();
+ Map result =
+ AguiJsonPatch.apply(target, List.of(JsonPatchOperation.add("/key", "value")));
+
+ assertEquals("value", result.get("key"));
+ }
+
+ @Test
+ void replaceOverwritesExistingValue() {
+ Map target = new java.util.LinkedHashMap<>();
+ target.put("key", "old");
+
+ Map result =
+ AguiJsonPatch.apply(target, List.of(JsonPatchOperation.replace("/key", "new")));
+
+ assertEquals("new", result.get("key"));
+ }
+
+ @Test
+ void removeDeletesKey() {
+ Map target = new java.util.LinkedHashMap<>();
+ target.put("key", "value");
+
+ Map result =
+ AguiJsonPatch.apply(target, List.of(JsonPatchOperation.remove("/key")));
+
+ assertTrue(!result.containsKey("key"));
+ }
+
+ @Test
+ void nestedPointersTraverseMaps() {
+ Map inner = new java.util.LinkedHashMap<>();
+ inner.put("inner", "old");
+ Map target = new java.util.LinkedHashMap<>();
+ target.put("nested", inner);
+
+ Map result =
+ AguiJsonPatch.apply(
+ target, List.of(JsonPatchOperation.replace("/nested/inner", "new")));
+
+ @SuppressWarnings("unchecked")
+ Map nested = (Map) result.get("nested");
+ assertEquals("new", nested.get("inner"));
+ }
+
+ @Test
+ void tildeIsUnescaped() {
+ Map target = new java.util.LinkedHashMap<>();
+
+ Map result =
+ AguiJsonPatch.apply(target, List.of(JsonPatchOperation.add("/key~0with", "value")));
+
+ assertEquals("value", result.get("key~with"));
+ }
+
+ @Test
+ void slashIsUnescaped() {
+ Map target = new java.util.LinkedHashMap<>();
+
+ Map result =
+ AguiJsonPatch.apply(target, List.of(JsonPatchOperation.add("/key~1with", "value")));
+
+ assertEquals("value", result.get("key/with"));
+ }
+
+ @Test
+ void addToListWithAppendToken() {
+ Map target = new java.util.LinkedHashMap<>();
+ target.put("items", new java.util.ArrayList<>(List.of(1, 2, 3)));
+
+ Map result =
+ AguiJsonPatch.apply(target, List.of(JsonPatchOperation.add("/items/-", 4)));
+
+ assertEquals(List.of(1, 2, 3, 4), result.get("items"));
+ }
+
+ @Test
+ void addToListAtIndexInserts() {
+ Map target = new java.util.LinkedHashMap<>();
+ target.put("items", new java.util.ArrayList<>(List.of(1, 2, 3)));
+
+ Map result =
+ AguiJsonPatch.apply(target, List.of(JsonPatchOperation.add("/items/1", 9)));
+
+ assertEquals(List.of(1, 9, 2, 3), result.get("items"));
+ }
+
+ @Test
+ void replaceListItem() {
+ Map target = new java.util.LinkedHashMap<>();
+ target.put("items", new java.util.ArrayList<>(List.of(1, 2, 3)));
+
+ Map result =
+ AguiJsonPatch.apply(target, List.of(JsonPatchOperation.replace("/items/0", 7)));
+
+ assertEquals(List.of(7, 2, 3), result.get("items"));
+ }
+
+ @Test
+ void removeListItem() {
+ Map target = new java.util.LinkedHashMap<>();
+ target.put("items", new java.util.ArrayList<>(List.of(1, 2, 3)));
+
+ Map result =
+ AguiJsonPatch.apply(target, List.of(JsonPatchOperation.remove("/items/1")));
+
+ assertEquals(List.of(1, 3), result.get("items"));
+ }
+
+ @Test
+ void unknownOpIsIgnored() {
+ Map target = new java.util.LinkedHashMap<>();
+ target.put("key", "value");
+
+ Map result =
+ AguiJsonPatch.apply(
+ target, List.of(new JsonPatchOperation("move", "/key", null, "/other")));
+
+ assertEquals("value", result.get("key"));
+ }
+
+ @Test
+ void doesNotMutateInput() {
+ Map target = new java.util.LinkedHashMap<>();
+ target.put("key", "old");
+
+ Map result =
+ AguiJsonPatch.apply(target, List.of(JsonPatchOperation.replace("/key", "new")));
+
+ assertNotSame(target, result);
+ assertEquals("old", target.get("key"));
+ }
+
+ @Test
+ void multipleOpsApplyInOrder() {
+ Map target = new java.util.LinkedHashMap<>();
+
+ Map result =
+ AguiJsonPatch.apply(
+ target,
+ List.of(
+ JsonPatchOperation.add("/a", 1),
+ JsonPatchOperation.add("/b", 2),
+ JsonPatchOperation.remove("/a")));
+
+ assertTrue(!result.containsKey("a"));
+ assertEquals(2, result.get("b"));
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/AguiSnapshotAccumulatorTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/AguiSnapshotAccumulatorTest.java
new file mode 100644
index 0000000000..f222c8fd7d
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/AguiSnapshotAccumulatorTest.java
@@ -0,0 +1,307 @@
+/*
+ * 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.store;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.agui.event.AguiEvent.JsonPatchOperation;
+import io.agentscope.core.agui.model.AguiMessage;
+import io.agentscope.core.agui.model.AguiToolCall;
+import io.agentscope.core.agui.model.RunAgentInput;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for the package-visible {@link AguiSnapshotAccumulator}, which absorbs the former
+ * CopilotKit replay workarounds.
+ */
+class AguiSnapshotAccumulatorTest {
+
+ private static final String THREAD = "thread-1";
+ private static final String RUN = "run-1";
+
+ private static AguiSnapshotAccumulator newAccumulator(AguiThreadSnapshot seed) {
+ return new AguiSnapshotAccumulator(THREAD, RUN, seed);
+ }
+
+ private static AguiThreadSnapshot materialize(
+ AguiSnapshotAccumulator acc, AguiEvent... events) {
+ for (AguiEvent event : events) {
+ acc.consume(event);
+ }
+ return acc.materialize();
+ }
+
+ @Test
+ void textFoldingProducesAssistantMessage() {
+ AguiThreadSnapshot snapshot =
+ materialize(
+ newAccumulator(null),
+ new AguiEvent.TextMessageStart(THREAD, RUN, "m1", "assistant"),
+ new AguiEvent.TextMessageContent(THREAD, RUN, "m1", "Hello "),
+ new AguiEvent.TextMessageContent(THREAD, RUN, "m1", "world"),
+ new AguiEvent.TextMessageEnd(THREAD, RUN, "m1"),
+ new AguiEvent.RunFinished(THREAD, RUN));
+
+ assertEquals(1, snapshot.messages().size());
+ AguiMessage assistant = snapshot.messages().get(0);
+ assertEquals("m1", assistant.getId());
+ assertEquals("assistant", assistant.getRole());
+ assertEquals("Hello world", assistant.getTextContent());
+ assertNull(snapshot.pendingOutcome());
+ }
+
+ @Test
+ void toolCallWithArgsAndResultAttachesToAssistant() {
+ AguiThreadSnapshot snapshot =
+ materialize(
+ newAccumulator(null),
+ new AguiEvent.TextMessageStart(THREAD, RUN, "m1", "assistant"),
+ new AguiEvent.TextMessageEnd(THREAD, RUN, "m1"),
+ new AguiEvent.ToolCallStart(THREAD, RUN, "tc1", "search"),
+ new AguiEvent.ToolCallArgs(THREAD, RUN, "tc1", "{\"q\":\"hi\"}"),
+ new AguiEvent.ToolCallEnd(THREAD, RUN, "tc1"),
+ new AguiEvent.ToolCallResult(
+ THREAD, RUN, "tc1", "result-body", "tool", "r1"),
+ new AguiEvent.RunFinished(THREAD, RUN));
+
+ // assistant message + tool result message
+ assertEquals(2, snapshot.messages().size());
+ AguiMessage assistant = snapshot.messages().get(0);
+ assertEquals(1, assistant.getToolCalls().size());
+ AguiToolCall call = assistant.getToolCalls().get(0);
+ assertEquals("tc1", call.getId());
+ assertEquals("search", call.getFunction().getName());
+ assertEquals("{\"q\":\"hi\"}", call.getFunction().getArguments());
+
+ AguiMessage tool = snapshot.messages().get(1);
+ assertEquals("tool", tool.getRole());
+ assertEquals("tc1", tool.getToolCallId());
+ assertEquals("result-body", tool.getTextContent());
+ }
+
+ @Test
+ void danglingToolCallGetsSyntheticResult() {
+ AguiThreadSnapshot snapshot =
+ materialize(
+ newAccumulator(null),
+ new AguiEvent.ToolCallStart(THREAD, RUN, "tc1", "search"),
+ new AguiEvent.ToolCallArgs(THREAD, RUN, "tc1", "{}"),
+ new AguiEvent.RunFinished(THREAD, RUN));
+
+ // One synthesized assistant message (holding the tool call) + one synthetic tool result.
+ assertEquals(2, snapshot.messages().size());
+ AguiMessage tool = snapshot.messages().get(1);
+ assertEquals("tool", tool.getRole());
+ assertEquals("tc1", tool.getToolCallId());
+ assertEquals("tc1:result", tool.getId());
+ assertNull(snapshot.pendingOutcome());
+ }
+
+ @Test
+ void danglingToolOfOpenInterruptIsLeftOpen() {
+ AguiEvent.RunFinishedOutcome interrupt =
+ new AguiEvent.RunFinishedInterruptOutcome(
+ List.of(
+ new AguiEvent.Interrupt(
+ "i1", "tool_call", "approve?", "tc1", null, null, null)));
+ AguiThreadSnapshot snapshot =
+ materialize(
+ newAccumulator(null),
+ new AguiEvent.ToolCallStart(THREAD, RUN, "tc1", "search"),
+ new AguiEvent.ToolCallArgs(THREAD, RUN, "tc1", "{}"),
+ new AguiEvent.RunFinished(THREAD, RUN, null, interrupt));
+
+ // The open interrupt is retained, and its tool is NOT given a synthetic result.
+ assertNotNull(snapshot.pendingOutcome());
+ assertEquals(1, snapshot.messages().size());
+ AguiMessage assistant = snapshot.messages().get(0);
+ assertEquals(1, assistant.getToolCalls().size());
+ assertEquals("tc1", assistant.getToolCalls().get(0).getId());
+ for (AguiMessage message : snapshot.messages()) {
+ assertFalse("tc1:result".equals(message.getId()));
+ }
+ }
+
+ @Test
+ void stateSnapshotThenDeltaFoldsToFinalState() {
+ AguiThreadSnapshot snapshot =
+ materialize(
+ newAccumulator(null),
+ new AguiEvent.StateSnapshot(
+ THREAD, RUN, Map.of("count", 1, "name", "alice")),
+ new AguiEvent.StateDelta(
+ THREAD,
+ RUN,
+ List.of(
+ JsonPatchOperation.replace("/count", 2),
+ JsonPatchOperation.add("active", true))),
+ new AguiEvent.RunFinished(THREAD, RUN));
+
+ assertEquals(2, snapshot.state().get("count"));
+ assertEquals("alice", snapshot.state().get("name"));
+ assertEquals(true, snapshot.state().get("active"));
+ }
+
+ @Test
+ void activitySnapshotAndDeltaAreFolded() {
+ AguiThreadSnapshot snapshot =
+ materialize(
+ newAccumulator(null),
+ new AguiEvent.ActivitySnapshot(
+ THREAD, RUN, "m1", "progress", Map.of("step", 1), true),
+ new AguiEvent.ActivityDelta(
+ THREAD,
+ RUN,
+ "m1",
+ "progress",
+ List.of(JsonPatchOperation.replace("/step", 2))),
+ new AguiEvent.RunFinished(THREAD, RUN));
+
+ assertEquals(1, snapshot.activities().size());
+ AguiThreadSnapshot.ActivityFrame frame = snapshot.activities().get(0);
+ assertEquals("m1", frame.messageId());
+ assertEquals("progress", frame.activityType());
+ assertEquals(2, frame.content().get("step"));
+ }
+
+ @Test
+ void trailingInterruptIsKept() {
+ AguiEvent.RunFinishedOutcome interrupt =
+ new AguiEvent.RunFinishedInterruptOutcome(
+ List.of(
+ new AguiEvent.Interrupt(
+ "i1", "tool_call", "msg", "tc1", null, null, null)));
+ AguiThreadSnapshot snapshot =
+ materialize(
+ newAccumulator(null),
+ new AguiEvent.RunFinished(THREAD, RUN, null, interrupt));
+
+ assertTrue(snapshot.pendingOutcome() instanceof AguiEvent.RunFinishedInterruptOutcome);
+ }
+
+ @Test
+ void danglingToolsOfMultiInterruptOutcomeAreAllLeftOpen() {
+ AguiEvent.RunFinishedOutcome interrupt =
+ new AguiEvent.RunFinishedInterruptOutcome(
+ List.of(
+ new AguiEvent.Interrupt(
+ "i1", "tool_call", "msg", "tc1", null, null, null),
+ new AguiEvent.Interrupt(
+ "i2", "tool_call", "msg", "tc2", null, null, null)));
+ AguiThreadSnapshot snapshot =
+ materialize(
+ newAccumulator(null),
+ new AguiEvent.ToolCallStart(THREAD, RUN, "tc1", "search"),
+ new AguiEvent.ToolCallArgs(THREAD, RUN, "tc1", "{}"),
+ new AguiEvent.ToolCallStart(THREAD, RUN, "tc2", "write"),
+ new AguiEvent.ToolCallArgs(THREAD, RUN, "tc2", "{}"),
+ new AguiEvent.RunFinished(THREAD, RUN, null, interrupt));
+
+ assertNotNull(snapshot.pendingOutcome());
+ // No synthetic results for either open-interrupt tool — both genuinely await the user.
+ for (AguiMessage message : snapshot.messages()) {
+ assertFalse("tc1:result".equals(message.getId()));
+ assertFalse("tc2:result".equals(message.getId()));
+ }
+ }
+
+ @Test
+ void successRunClearsPriorInterruptInSameAccumulator() {
+ AguiEvent.RunFinishedOutcome interrupt =
+ new AguiEvent.RunFinishedInterruptOutcome(
+ List.of(
+ new AguiEvent.Interrupt(
+ "i1", "tool_call", "msg", "tc1", null, null, null)));
+ AguiSnapshotAccumulator acc = newAccumulator(null);
+ acc.consume(new AguiEvent.RunFinished(THREAD, RUN, null, interrupt));
+ assertNotNull(acc.materialize().pendingOutcome());
+
+ acc.consume(new AguiEvent.RunFinished(THREAD, RUN));
+ assertNull(acc.materialize().pendingOutcome());
+ }
+
+ @Test
+ void seedsFromExistingSnapshotButDoesNotCarryInterrupt() {
+ AguiEvent.RunFinishedOutcome interrupt =
+ new AguiEvent.RunFinishedInterruptOutcome(
+ List.of(
+ new AguiEvent.Interrupt(
+ "i1", "tool_call", "msg", "tc1", null, null, null)));
+ AguiThreadSnapshot seed =
+ new AguiThreadSnapshot(
+ THREAD,
+ List.of(AguiMessage.userMessage("u1", "hi")),
+ Map.of("k", "v"),
+ List.of(
+ new AguiThreadSnapshot.ActivityFrame(
+ "m0", "progress", Map.of("step", 1))),
+ interrupt,
+ "run-0",
+ 1L);
+
+ AguiThreadSnapshot snapshot =
+ materialize(newAccumulator(seed), new AguiEvent.RunFinished(THREAD, RUN));
+
+ // Prior messages / state / activities survive, but the prior interrupt does not.
+ assertEquals("hi", snapshot.messages().get(0).getTextContent());
+ assertEquals("v", snapshot.state().get("k"));
+ assertEquals(1, snapshot.activities().size());
+ assertNull(snapshot.pendingOutcome());
+ }
+
+ @Test
+ void runStartedCapturesInputMessagesDeDuped() {
+ RunAgentInput input =
+ RunAgentInput.builder()
+ .threadId(THREAD)
+ .runId(RUN)
+ .messages(List.of(AguiMessage.userMessage("u1", "hi")))
+ .build();
+ AguiThreadSnapshot snapshot =
+ materialize(
+ newAccumulator(null),
+ new AguiEvent.RunStarted(THREAD, RUN, null, input),
+ new AguiEvent.TextMessageStart(THREAD, RUN, "m1", "assistant"),
+ new AguiEvent.TextMessageEnd(THREAD, RUN, "m1"),
+ new AguiEvent.RunFinished(THREAD, RUN));
+
+ // u1 from input + m1 assistant
+ assertEquals(2, snapshot.messages().size());
+ assertEquals("u1", snapshot.messages().get(0).getId());
+ }
+
+ @Test
+ void runErrorClearsPendingOutcome() {
+ AguiSnapshotAccumulator acc = newAccumulator(null);
+ AguiEvent.RunFinishedOutcome interrupt =
+ new AguiEvent.RunFinishedInterruptOutcome(
+ List.of(
+ new AguiEvent.Interrupt(
+ "i1", "tool_call", "msg", "tc1", null, null, null)));
+ acc.consume(new AguiEvent.RunFinished(THREAD, RUN, null, interrupt));
+ assertNotNull(acc.materialize().pendingOutcome());
+
+ acc.consume(new AguiEvent.RunError(THREAD, RUN, "boom", "ERR"));
+ assertNull(acc.materialize().pendingOutcome());
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/AguiSnapshotHydratorTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/AguiSnapshotHydratorTest.java
new file mode 100644
index 0000000000..514ed4157f
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/AguiSnapshotHydratorTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.store;
+
+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 io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.agui.event.AguiEventType;
+import io.agentscope.core.agui.model.AguiMessage;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link AguiSnapshotHydrator}.
+ */
+class AguiSnapshotHydratorTest {
+
+ private final AguiSnapshotHydrator hydrator = new AguiSnapshotHydrator();
+
+ @Test
+ void emptySnapshotProducesThreeFrameHandshake() {
+ List frames = hydrator.hydrate(null, "t1", "r1");
+
+ assertEquals(3, frames.size());
+ assertEquals(AguiEventType.RUN_STARTED, frames.get(0).getType());
+ assertTrue(frames.get(1) instanceof AguiEvent.MessagesSnapshot);
+ assertTrue(frames.get(2) instanceof AguiEvent.RunFinished);
+ assertEquals("t1", frames.get(0).getThreadId());
+ assertEquals("r1", frames.get(0).getRunId());
+ }
+
+ @Test
+ void snapshotProducesFullFrameOrder() {
+ AguiThreadSnapshot snapshot =
+ new AguiThreadSnapshot(
+ "t1",
+ List.of(AguiMessage.userMessage("u1", "hi")),
+ Map.of("count", 2),
+ List.of(
+ new AguiThreadSnapshot.ActivityFrame(
+ "u1", "progress", Map.of("step", 1))),
+ null,
+ "r0",
+ 1L);
+
+ List frames = hydrator.hydrate(snapshot, "t1", "r1");
+
+ // RUN_STARTED, MESSAGES_SNAPSHOT, STATE_SNAPSHOT, ACTIVITY_SNAPSHOT, RUN_FINISHED
+ assertEquals(5, frames.size());
+ assertEquals(AguiEventType.RUN_STARTED, frames.get(0).getType());
+ assertTrue(frames.get(1) instanceof AguiEvent.MessagesSnapshot);
+ assertTrue(frames.get(2) instanceof AguiEvent.StateSnapshot);
+ assertTrue(frames.get(3) instanceof AguiEvent.ActivitySnapshot);
+ assertTrue(frames.get(4) instanceof AguiEvent.RunFinished);
+ }
+
+ @Test
+ void stateOmittedWhenEmpty() {
+ AguiThreadSnapshot snapshot =
+ new AguiThreadSnapshot(
+ "t1",
+ List.of(AguiMessage.userMessage("u1", "hi")),
+ Map.of(),
+ List.of(),
+ null,
+ "r0",
+ 1L);
+
+ List frames = hydrator.hydrate(snapshot, "t1", "r1");
+
+ // RUN_STARTED, MESSAGES_SNAPSHOT, RUN_FINISHED (no state, no activities)
+ assertEquals(3, frames.size());
+ assertTrue(frames.get(1) instanceof AguiEvent.MessagesSnapshot);
+ assertTrue(frames.get(2) instanceof AguiEvent.RunFinished);
+ }
+
+ @Test
+ void pendingOutcomePropagatesToRunFinished() {
+ AguiEvent.RunFinishedOutcome outcome =
+ new AguiEvent.RunFinishedInterruptOutcome(
+ List.of(
+ new AguiEvent.Interrupt(
+ "i1", "tool_call", "msg", "tc1", null, null, null)));
+ AguiThreadSnapshot snapshot =
+ new AguiThreadSnapshot("t1", List.of(), Map.of(), List.of(), outcome, "r0", 1L);
+
+ List frames = hydrator.hydrate(snapshot, "t1", "r1");
+ AguiEvent.RunFinished finished = (AguiEvent.RunFinished) frames.get(frames.size() - 1);
+ assertEquals(outcome, finished.outcome());
+ }
+
+ @Test
+ void nullPendingOutcomeSerializesAsSuccess() {
+ AguiThreadSnapshot snapshot =
+ new AguiThreadSnapshot("t1", List.of(), Map.of(), List.of(), null, "r0", 1L);
+
+ List frames = hydrator.hydrate(snapshot, "t1", "r1");
+ AguiEvent.RunFinished finished = (AguiEvent.RunFinished) frames.get(frames.size() - 1);
+ assertNull(finished.outcome());
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/InMemoryAguiSnapshotStoreTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/InMemoryAguiSnapshotStoreTest.java
new file mode 100644
index 0000000000..923c3ab112
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/InMemoryAguiSnapshotStoreTest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.core.agui.store;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.agui.model.AguiMessage;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link InMemoryAguiSnapshotStore}.
+ */
+class InMemoryAguiSnapshotStoreTest {
+
+ private static AguiThreadSnapshot snapshot(String threadId, long updatedAt) {
+ return new AguiThreadSnapshot(
+ threadId,
+ List.of(AguiMessage.userMessage(threadId, "hi")),
+ Map.of(),
+ List.of(),
+ null,
+ "run-1",
+ updatedAt);
+ }
+
+ private static AguiThreadSnapshot snapshotWithInterrupt(String threadId, long updatedAt) {
+ return new AguiThreadSnapshot(
+ threadId,
+ List.of(AguiMessage.userMessage(threadId, "hi")),
+ Map.of(),
+ List.of(),
+ new AguiEvent.RunFinishedInterruptOutcome(
+ List.of(
+ new AguiEvent.Interrupt(
+ "i1", "tool_call", "msg", "tc1", null, null, null))),
+ "run-1",
+ updatedAt);
+ }
+
+ @Test
+ void saveFindDelete() {
+ InMemoryAguiSnapshotStore store = new InMemoryAguiSnapshotStore();
+ store.save(snapshot("t1", 1L));
+
+ Optional found = store.find("t1");
+ assertTrue(found.isPresent());
+ assertEquals("t1", found.get().threadId());
+
+ store.delete("t1");
+ assertTrue(store.find("t1").isEmpty());
+ }
+
+ @Test
+ void clearPendingInterruptsDropsTrailingInterrupt() {
+ InMemoryAguiSnapshotStore store = new InMemoryAguiSnapshotStore();
+ store.save(snapshotWithInterrupt("t1", 1L));
+
+ store.clearPendingInterrupts("t1");
+
+ AguiThreadSnapshot found = store.find("t1").orElseThrow();
+ assertEquals(null, found.pendingOutcome());
+ // Messages are preserved.
+ assertEquals(1, found.messages().size());
+ }
+
+ @Test
+ void clearPendingInterruptsNoOpWhenNoInterrupt() {
+ InMemoryAguiSnapshotStore store = new InMemoryAguiSnapshotStore();
+ store.save(snapshot("t1", 1L));
+ // Should not throw and should not alter the snapshot.
+ store.clearPendingInterrupts("t1");
+ assertEquals(null, store.find("t1").orElseThrow().pendingOutcome());
+ }
+
+ @Test
+ void overflowEvictsOldest() {
+ InMemoryAguiSnapshotStore store = new InMemoryAguiSnapshotStore(2);
+ store.save(snapshot("t1", 1L));
+ store.save(snapshot("t2", 2L));
+ store.save(snapshot("t3", 3L));
+
+ assertFalse(store.find("t1").isPresent()); // oldest evicted
+ assertTrue(store.find("t2").isPresent());
+ assertTrue(store.find("t3").isPresent());
+ }
+
+ @Test
+ void rejectsNonPositiveCapacity() {
+ assertThrows(IllegalArgumentException.class, () -> new InMemoryAguiSnapshotStore(0));
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/SnapshotRecordingEnricherTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/SnapshotRecordingEnricherTest.java
new file mode 100644
index 0000000000..f949f70b33
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/store/SnapshotRecordingEnricherTest.java
@@ -0,0 +1,105 @@
+/*
+ * 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.store;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.agentscope.core.agui.adapter.AguiAdapterConfig;
+import io.agentscope.core.agui.adapter.strategy.AguiStreamContext;
+import io.agentscope.core.agui.event.AguiEvent;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link SnapshotRecordingEnricher}.
+ */
+class SnapshotRecordingEnricherTest {
+
+ private static AguiStreamContext context() {
+ return new AguiStreamContext("t1", "r1", AguiAdapterConfig.defaultConfig());
+ }
+
+ @Test
+ void savesSnapshotOnTerminalFrame() {
+ InMemoryAguiSnapshotStore store = new InMemoryAguiSnapshotStore();
+ SnapshotRecordingEnricher enricher = new SnapshotRecordingEnricher(store);
+
+ List events =
+ List.of(
+ new AguiEvent.TextMessageStart("t1", "r1", "m1", "assistant"),
+ new AguiEvent.TextMessageContent("t1", "r1", "m1", "hi"),
+ new AguiEvent.TextMessageEnd("t1", "r1", "m1"),
+ new AguiEvent.RunFinished("t1", "r1"));
+
+ List result = enricher.enrich(null, events, context());
+
+ assertEquals(events, result); // events returned unmodified
+ assertTrue(store.find("t1").isPresent());
+ AguiThreadSnapshot snapshot = store.find("t1").orElseThrow();
+ assertEquals(1, snapshot.messages().size());
+ assertEquals("hi", snapshot.messages().get(0).getTextContent());
+ assertEquals("r1", snapshot.lastRunId());
+ }
+
+ @Test
+ void flushSavesOnAbnormalTermination() {
+ InMemoryAguiSnapshotStore store = new InMemoryAguiSnapshotStore();
+ SnapshotRecordingEnricher enricher = new SnapshotRecordingEnricher(store);
+
+ // Non-terminal events only — simulates a stream that died without a terminal frame.
+ enricher.enrich(
+ null,
+ List.of(
+ new AguiEvent.TextMessageStart("t1", "r1", "m1", "assistant"),
+ new AguiEvent.TextMessageContent("t1", "r1", "m1", "partial")),
+ context());
+
+ assertTrue(store.find("t1").isEmpty()); // not saved yet
+
+ enricher.flush("t1", "r1");
+
+ assertTrue(store.find("t1").isPresent());
+ assertEquals("partial", store.find("t1").orElseThrow().messages().get(0).getTextContent());
+ }
+
+ @Test
+ void flushAfterTerminalIsNoOp() {
+ InMemoryAguiSnapshotStore store = new InMemoryAguiSnapshotStore();
+ SnapshotRecordingEnricher enricher = new SnapshotRecordingEnricher(store);
+
+ enricher.enrich(
+ null,
+ List.of(
+ new AguiEvent.TextMessageStart("t1", "r1", "m1", "assistant"),
+ new AguiEvent.TextMessageEnd("t1", "r1", "m1"),
+ new AguiEvent.RunFinished("t1", "r1")),
+ context());
+
+ // Already saved on the terminal frame; flush must not throw or duplicate.
+ enricher.flush("t1", "r1");
+
+ assertTrue(store.find("t1").isPresent());
+ assertEquals(1, store.find("t1").orElseThrow().messages().size());
+ }
+
+ @Test
+ void nullStoreIsNoOp() {
+ SnapshotRecordingEnricher enricher = new SnapshotRecordingEnricher(null);
+ List events = List.of(new AguiEvent.RunFinished("t1", "r1"));
+ assertEquals(events, enricher.enrich(null, events, context()));
+ }
+}
diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/common/AguiProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/common/AguiProperties.java
index b263e5b101..95f01838f4 100644
--- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/common/AguiProperties.java
+++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/common/AguiProperties.java
@@ -41,6 +41,8 @@
* emit-token-usage: false
* emit-run-finished-after-error: false
* interrupt-on-disconnect: true
+ * snapshot-store-enabled: false
+ * snapshot-max-threads: 1000
*
*/
@ConfigurationProperties(prefix = "agentscope.agui")
@@ -129,6 +131,20 @@ public class AguiProperties {
/** Whether to interrupt the agent when the client disconnects. */
private boolean interruptOnDisconnect = true;
+ /**
+ * Whether the AG-UI presentation snapshot store is enabled. When enabled, an in-memory
+ * {@code AguiSnapshotStore} bean is created and the {@code POST {path-prefix}/connect} hydrate
+ * route is registered so reconnecting clients can rebuild the visible conversation without
+ * re-running the agent. Default is {@code false} to keep existing clients byte-identical.
+ */
+ private boolean snapshotStoreEnabled = false;
+
+ /**
+ * Maximum number of threads retained by the in-memory snapshot store. Only used when
+ * {@link #isSnapshotStoreEnabled()} is true.
+ */
+ private int snapshotMaxThreads = 1000;
+
public String getPathPrefix() {
return pathPrefix;
}
@@ -272,4 +288,20 @@ public boolean isInterruptOnDisconnect() {
public void setInterruptOnDisconnect(boolean interruptOnDisconnect) {
this.interruptOnDisconnect = interruptOnDisconnect;
}
+
+ public boolean isSnapshotStoreEnabled() {
+ return snapshotStoreEnabled;
+ }
+
+ public void setSnapshotStoreEnabled(boolean snapshotStoreEnabled) {
+ this.snapshotStoreEnabled = snapshotStoreEnabled;
+ }
+
+ public int getSnapshotMaxThreads() {
+ return snapshotMaxThreads;
+ }
+
+ public void setSnapshotMaxThreads(int snapshotMaxThreads) {
+ this.snapshotMaxThreads = snapshotMaxThreads;
+ }
}
diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AgentscopeAguiMvcAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AgentscopeAguiMvcAutoConfiguration.java
index 03668fc9da..61ca08220e 100644
--- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AgentscopeAguiMvcAutoConfiguration.java
+++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AgentscopeAguiMvcAutoConfiguration.java
@@ -23,6 +23,8 @@
import io.agentscope.core.agui.registry.AguiAgentRegistry;
import io.agentscope.core.agui.runtime.AguiRequestBodyParser;
import io.agentscope.core.agui.runtime.AguiRuntimeContextResolver;
+import io.agentscope.core.agui.store.AguiSnapshotStore;
+import io.agentscope.core.agui.store.InMemoryAguiSnapshotStore;
import io.agentscope.spring.boot.agui.common.AguiProperties;
import io.agentscope.spring.boot.agui.common.ThreadSessionManager;
import org.springframework.beans.factory.ObjectProvider;
@@ -30,6 +32,7 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -83,6 +86,22 @@ public AguiRequestBodyParser aguiRequestBodyParser() {
return new AguiRequestBodyParser();
}
+ /**
+ * Creates the in-memory AG-UI presentation snapshot store when the snapshot store is enabled.
+ *
+ * @param props The configuration properties
+ * @return A new in-memory snapshot store
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(
+ prefix = "agentscope.agui",
+ name = "snapshot-store-enabled",
+ havingValue = "true")
+ public AguiSnapshotStore aguiSnapshotStore(AguiProperties props) {
+ return new InMemoryAguiSnapshotStore(props.getSnapshotMaxThreads());
+ }
+
/**
* Creates the AG-UI MVC controller bean.
*
@@ -100,7 +119,9 @@ public AguiMvcController aguiMvcController(
ObjectProvider eventConvertersProvider,
ObjectProvider eventEnrichersProvider,
ObjectProvider runtimeContextResolverProvider,
- ObjectProvider adapterFactoryProvider) {
+ ObjectProvider adapterFactoryProvider,
+ ObjectProvider snapshotStoreProvider) {
+ AguiSnapshotStore snapshotStore = snapshotStoreProvider.getIfAvailable();
AguiAdapterConfig config =
AguiAdapterConfig.builder()
.toolMergeMode(props.getDefaultToolMergeMode())
@@ -113,6 +134,8 @@ public AguiMvcController aguiMvcController(
.defaultAgentId(props.getDefaultAgentId())
.eventConverters(eventConvertersProvider.orderedStream().toList())
.eventEnrichers(eventEnrichersProvider.orderedStream().toList())
+ .snapshotStoreEnabled(props.isSnapshotStoreEnabled())
+ .snapshotStore(snapshotStore)
.build();
return AguiMvcController.builder()
@@ -124,6 +147,7 @@ public AguiMvcController aguiMvcController(
.interruptOnDisconnect(props.isInterruptOnDisconnect())
.runtimeContextResolver(runtimeContextResolverProvider.getIfAvailable())
.adapterFactory(adapterFactoryProvider.getIfAvailable())
+ .snapshotStore(snapshotStore)
.config(config)
.build();
}
@@ -147,4 +171,23 @@ public AguiRestController aguiRestController(
props.isEnablePathRouting(),
requestBodyParser);
}
+
+ /**
+ * Creates the MVC {@code /connect} hydrate controller, only when the snapshot store is enabled,
+ * so the MVC route mirrors the WebFlux {@code RouterFunction} gating.
+ *
+ * @param aguiMvcController The AG-UI MVC controller
+ * @param requestBodyParser The parser used to decode request bodies
+ * @return A new AguiConnectController
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(
+ prefix = "agentscope.agui",
+ name = "snapshot-store-enabled",
+ havingValue = "true")
+ public AguiConnectController aguiConnectController(
+ AguiMvcController aguiMvcController, AguiRequestBodyParser requestBodyParser) {
+ return new AguiConnectController(aguiMvcController, requestBodyParser);
+ }
}
diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AguiConnectController.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AguiConnectController.java
new file mode 100644
index 0000000000..e4d52326e4
--- /dev/null
+++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AguiConnectController.java
@@ -0,0 +1,101 @@
+/*
+ * 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.spring.boot.agui.mvc;
+
+import io.agentscope.core.agui.encoder.AguiEventEncoder;
+import io.agentscope.core.agui.event.AguiEvent;
+import io.agentscope.core.agui.model.RunAgentInput;
+import io.agentscope.core.agui.runtime.AguiRequestBodyParser;
+import io.agentscope.core.util.JsonException;
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.Map;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+/**
+ * MVC REST controller for the AG-UI {@code /connect} hydrate route.
+ *
+ * Registered as a conditional bean only when the presentation snapshot store is enabled, so the
+ * MVC {@code POST {path-prefix}/connect} route mirrors the WebFlux {@code RouterFunction} gating:
+ * absent (404) when the store is off, present when on. When off, hydrate has nothing to read and
+ * clients should not rely on the endpoint.
+ *
+ *
Delegates to {@link AguiMvcController#handleConnect} for the read-only SSE stream.
+ */
+@RestController
+public class AguiConnectController {
+
+ private final AguiMvcController aguiMvcController;
+ private final AguiRequestBodyParser requestBodyParser;
+ private final AguiEventEncoder encoder = new AguiEventEncoder();
+
+ /**
+ * Creates a new AguiConnectController.
+ *
+ * @param aguiMvcController The AG-UI MVC controller
+ * @param requestBodyParser The parser used to decode request bodies
+ */
+ public AguiConnectController(
+ AguiMvcController aguiMvcController, AguiRequestBodyParser requestBodyParser) {
+ this.aguiMvcController = aguiMvcController;
+ this.requestBodyParser = requestBodyParser;
+ }
+
+ /**
+ * Handle an AG-UI {@code /connect} hydrate request.
+ *
+ * @param body The raw run agent input JSON (threadId / runId identify the snapshot)
+ * @param request The native servlet request (may carry headers for the runtime context)
+ * @return An SseEmitter for the read-only hydrate SSE stream
+ */
+ @PostMapping(
+ value = "${agentscope.agui.path-prefix:/agui}/connect",
+ consumes = MediaType.APPLICATION_JSON_VALUE,
+ produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ public SseEmitter connect(@RequestBody String body, HttpServletRequest request) {
+ RunAgentInput input = requestBodyParser.parse(body);
+ return aguiMvcController.handleConnect(input, request);
+ }
+
+ /**
+ * Return HTTP 400 for AG-UI request body parsing failures.
+ *
+ * @param error the JSON parse failure
+ * @return an SSE-compatible bad request response
+ */
+ @ExceptionHandler(JsonException.class)
+ public ResponseEntity handleParseError(JsonException error) {
+ String errorEvent =
+ encoder.encodeToJson(
+ new AguiEvent.Raw(
+ "unknown",
+ "unknown",
+ Map.of(
+ "error",
+ "Failed to parse request: " + error.getMessage())))
+ .trim();
+ String finishEvent =
+ encoder.encodeToJson(new AguiEvent.RunFinished("unknown", "unknown")).trim();
+ return ResponseEntity.badRequest()
+ .contentType(MediaType.TEXT_EVENT_STREAM)
+ .body("data: " + errorEvent + "\n\n" + "data: " + finishEvent + "\n\n");
+ }
+}
diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AguiMvcController.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AguiMvcController.java
index 1558151f08..547815001a 100644
--- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AguiMvcController.java
+++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/mvc/AguiMvcController.java
@@ -25,6 +25,7 @@
import io.agentscope.core.agui.registry.AguiAgentRegistry;
import io.agentscope.core.agui.runtime.AguiRuntimeContextRequest;
import io.agentscope.core.agui.runtime.AguiRuntimeContextResolver;
+import io.agentscope.core.agui.store.AguiSnapshotStore;
import io.agentscope.spring.boot.agui.common.DefaultAgentResolver;
import io.agentscope.spring.boot.agui.common.ThreadSessionManager;
import jakarta.servlet.http.HttpServletRequest;
@@ -41,6 +42,7 @@
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import reactor.core.Disposable;
import reactor.core.publisher.BaseSubscriber;
+import reactor.core.publisher.Flux;
/**
* MVC controller for AG-UI protocol requests.
@@ -94,6 +96,7 @@ private AguiMvcController(Builder builder) {
: AguiAdapterConfig.defaultConfig())
.adapterFactory(builder.adapterFactory)
.runtimeContextResolver(builder.runtimeContextResolver)
+ .snapshotStore(builder.snapshotStore)
.build();
this.encoder = new AguiEventEncoder();
this.agentIdHeader =
@@ -157,6 +160,76 @@ public SseEmitter handleWithAgentId(
return handleInternal(input, headerAgentId, pathAgentId, request);
}
+ /**
+ * Handle an AG-UI {@code /connect} hydrate request.
+ *
+ * Returns a read-only SSE stream reconstructed from the presentation snapshot store. No
+ * agent is invoked and there is no cancel-time interrupt, because hydrate has no live agent.
+ *
+ * @param input The run agent input (threadId / runId identify the snapshot)
+ * @param request The native servlet request (may be null)
+ * @return An SseEmitter for the hydrate SSE stream
+ */
+ public SseEmitter handleConnect(RunAgentInput input, HttpServletRequest request) {
+ SseEmitter emitter = new SseEmitter(sseTimeout);
+ String threadId = input.getThreadId();
+ String runId = input.getRunId();
+ executorService.submit(
+ () -> {
+ try {
+ Flux events =
+ processor.hydrate(
+ runtimeContextRequest(input, null, null, request));
+ BaseSubscriber subscription =
+ new BaseSubscriber<>() {
+ @Override
+ protected void hookOnNext(AguiEvent event) {
+ sendEvent(emitter, event);
+ }
+
+ @Override
+ protected void hookOnError(Throwable error) {
+ logger.error(
+ "Error during AG-UI hydrate: {}",
+ error.getMessage());
+ sendErrorAndComplete(
+ emitter, threadId, runId, error.getMessage());
+ }
+
+ @Override
+ protected void hookOnComplete() {
+ try {
+ emitter.complete();
+ } catch (Exception e) {
+ logger.debug(
+ "Error completing emitter: {}", e.getMessage());
+ }
+ }
+ };
+ emitter.onCompletion(
+ () -> logger.debug("SSE hydrate completed for run {}", runId));
+ emitter.onTimeout(
+ () -> {
+ subscription.dispose();
+ logger.debug("SSE hydrate timed out for run {}", runId);
+ });
+ emitter.onError(
+ (ex) -> {
+ subscription.dispose();
+ logger.debug(
+ "SSE hydrate error for run {}: {}",
+ runId,
+ ex.getMessage());
+ });
+ events.subscribe(subscription);
+ } catch (Exception e) {
+ logger.error("Error processing AG-UI connect: {}", e.getMessage());
+ sendErrorAndComplete(emitter, threadId, runId, e.getMessage());
+ }
+ });
+ return emitter;
+ }
+
private SseEmitter handleInternal(
RunAgentInput input,
String headerAgentId,
@@ -360,6 +433,7 @@ public static class Builder {
private boolean interruptOnDisconnect = true;
private AguiRuntimeContextResolver runtimeContextResolver;
private AguiAgentAdapterFactory adapterFactory;
+ private AguiSnapshotStore snapshotStore;
/**
* Set the agent registry.
@@ -460,6 +534,18 @@ public Builder adapterFactory(AguiAgentAdapterFactory adapterFactory) {
return this;
}
+ /**
+ * Set the presentation snapshot store used for {@code /connect} hydrate and trailing
+ * interrupt clearing. Optional; only effective when the snapshot store is enabled.
+ *
+ * @param snapshotStore the snapshot store, or null
+ * @return This builder
+ */
+ public Builder snapshotStore(AguiSnapshotStore snapshotStore) {
+ this.snapshotStore = snapshotStore;
+ return this;
+ }
+
/**
* Build the controller.
*
diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AgentscopeAguiWebFluxAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AgentscopeAguiWebFluxAutoConfiguration.java
index d5f17713a6..dcce3f1eb7 100644
--- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AgentscopeAguiWebFluxAutoConfiguration.java
+++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AgentscopeAguiWebFluxAutoConfiguration.java
@@ -23,6 +23,8 @@
import io.agentscope.core.agui.registry.AguiAgentRegistry;
import io.agentscope.core.agui.runtime.AguiRequestBodyParser;
import io.agentscope.core.agui.runtime.AguiRuntimeContextResolver;
+import io.agentscope.core.agui.store.AguiSnapshotStore;
+import io.agentscope.core.agui.store.InMemoryAguiSnapshotStore;
import io.agentscope.spring.boot.agui.common.AguiProperties;
import io.agentscope.spring.boot.agui.common.ThreadSessionManager;
import org.springframework.beans.factory.ObjectProvider;
@@ -30,6 +32,7 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -86,6 +89,22 @@ public AguiRequestBodyParser aguiRequestBodyParser() {
return new AguiRequestBodyParser();
}
+ /**
+ * Creates the in-memory AG-UI presentation snapshot store when the snapshot store is enabled.
+ *
+ * @param props The configuration properties
+ * @return A new in-memory snapshot store
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(
+ prefix = "agentscope.agui",
+ name = "snapshot-store-enabled",
+ havingValue = "true")
+ public AguiSnapshotStore aguiSnapshotStore(AguiProperties props) {
+ return new InMemoryAguiSnapshotStore(props.getSnapshotMaxThreads());
+ }
+
/**
* Creates the AG-UI WebFlux handler bean.
*
@@ -104,7 +123,9 @@ public AguiWebFluxHandler aguiWebFluxHandler(
ObjectProvider eventEnrichersProvider,
ObjectProvider runtimeContextResolverProvider,
ObjectProvider adapterFactoryProvider,
+ ObjectProvider snapshotStoreProvider,
AguiRequestBodyParser requestBodyParser) {
+ AguiSnapshotStore snapshotStore = snapshotStoreProvider.getIfAvailable();
AguiAdapterConfig config =
AguiAdapterConfig.builder()
.toolMergeMode(props.getDefaultToolMergeMode())
@@ -117,6 +138,8 @@ public AguiWebFluxHandler aguiWebFluxHandler(
.defaultAgentId(props.getDefaultAgentId())
.eventConverters(eventConvertersProvider.orderedStream().toList())
.eventEnrichers(eventEnrichersProvider.orderedStream().toList())
+ .snapshotStoreEnabled(props.isSnapshotStoreEnabled())
+ .snapshotStore(snapshotStore)
.build();
return AguiWebFluxHandler.builder()
@@ -128,6 +151,7 @@ public AguiWebFluxHandler aguiWebFluxHandler(
.runtimeContextResolver(runtimeContextResolverProvider.getIfAvailable())
.adapterFactory(adapterFactoryProvider.getIfAvailable())
.requestBodyParser(requestBodyParser)
+ .snapshotStore(snapshotStore)
.config(config)
.build();
}
@@ -158,6 +182,11 @@ public RouterFunction aguiRoutes(
props.getPathPrefix() + "/run/{agentId}", handler::handleWithAgentId);
}
+ // Register the read-only hydrate route when the snapshot store is enabled.
+ if (props.isSnapshotStoreEnabled()) {
+ routerBuilder.POST(props.getPathPrefix() + "/connect", handler::handleConnect);
+ }
+
return routerBuilder.build();
}
}
diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AguiWebFluxHandler.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AguiWebFluxHandler.java
index dbf06d8ec2..5cfb9aae87 100644
--- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AguiWebFluxHandler.java
+++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/main/java/io/agentscope/spring/boot/agui/webflux/AguiWebFluxHandler.java
@@ -26,6 +26,7 @@
import io.agentscope.core.agui.runtime.AguiRequestBodyParser;
import io.agentscope.core.agui.runtime.AguiRuntimeContextRequest;
import io.agentscope.core.agui.runtime.AguiRuntimeContextResolver;
+import io.agentscope.core.agui.store.AguiSnapshotStore;
import io.agentscope.spring.boot.agui.common.DefaultAgentResolver;
import io.agentscope.spring.boot.agui.common.ThreadSessionManager;
import java.util.LinkedHashMap;
@@ -97,6 +98,7 @@ private AguiWebFluxHandler(Builder builder) {
: AguiAdapterConfig.defaultConfig())
.adapterFactory(builder.adapterFactory)
.runtimeContextResolver(builder.runtimeContextResolver)
+ .snapshotStore(builder.snapshotStore)
.build();
this.encoder = new AguiEventEncoder();
this.requestBodyParser =
@@ -141,6 +143,37 @@ public Mono handleWithAgentId(ServerRequest request) {
.onErrorResume(this::handleParseError);
}
+ /**
+ * Handle an AG-UI {@code /connect} hydrate request.
+ *
+ * Parses the request body as {@link RunAgentInput} and returns a read-only SSE stream of
+ * frames reconstructed from the presentation snapshot store. No agent is invoked and there is
+ * no cancel-time interrupt, because hydrate has no live agent.
+ *
+ * @param request The server request
+ * @return A Mono containing the server response with the hydrate SSE stream
+ */
+ public Mono handleConnect(ServerRequest request) {
+ return request.bodyToMono(String.class)
+ .map(requestBodyParser::parse)
+ .flatMap(input -> hydrateInput(input, request))
+ .onErrorResume(this::handleParseError);
+ }
+
+ private Mono hydrateInput(RunAgentInput input, ServerRequest request) {
+ Flux events =
+ processor.hydrate(runtimeContextRequest(input, null, null, request));
+ Flux> sseStream =
+ events.map(
+ event ->
+ ServerSentEvent.builder()
+ .data(encoder.encodeToJson(event).trim())
+ .build());
+ return ServerResponse.ok()
+ .contentType(MediaType.TEXT_EVENT_STREAM)
+ .body(sseStream, ServerSentEvent.class);
+ }
+
private Mono processInput(
RunAgentInput input, ServerRequest request, String pathAgentId) {
String threadId = input.getThreadId();
@@ -292,6 +325,7 @@ public static class Builder {
private AguiRuntimeContextResolver runtimeContextResolver;
private AguiAgentAdapterFactory adapterFactory;
private AguiRequestBodyParser requestBodyParser;
+ private AguiSnapshotStore snapshotStore;
/**
* Set the agent registry.
@@ -392,6 +426,18 @@ public Builder requestBodyParser(AguiRequestBodyParser requestBodyParser) {
return this;
}
+ /**
+ * Set the presentation snapshot store used for {@code /connect} hydrate and trailing
+ * interrupt clearing. Optional; only effective when the snapshot store is enabled.
+ *
+ * @param snapshotStore the snapshot store, or null
+ * @return This builder
+ */
+ public Builder snapshotStore(AguiSnapshotStore snapshotStore) {
+ this.snapshotStore = snapshotStore;
+ return this;
+ }
+
/**
* Build the handler.
*
diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/test/java/io/agentscope/spring/boot/agui/common/AguiAdapterConfigAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/test/java/io/agentscope/spring/boot/agui/common/AguiAdapterConfigAutoConfigurationTest.java
index 28655acddd..a6b65cb016 100644
--- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/test/java/io/agentscope/spring/boot/agui/common/AguiAdapterConfigAutoConfigurationTest.java
+++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-agui-spring-boot-starter/src/test/java/io/agentscope/spring/boot/agui/common/AguiAdapterConfigAutoConfigurationTest.java
@@ -15,9 +15,12 @@
*/
package io.agentscope.spring.boot.agui.common;
+import static org.junit.Assert.assertNotNull;
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.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -32,6 +35,7 @@
import io.agentscope.core.agui.registry.AguiAgentRegistry;
import io.agentscope.core.agui.runtime.AguiRuntimeContextRequest;
import io.agentscope.core.agui.runtime.AguiRuntimeContextResolver;
+import io.agentscope.core.agui.store.AguiSnapshotStore;
import io.agentscope.core.event.AgentEndEvent;
import io.agentscope.core.event.AgentEvent;
import io.agentscope.core.event.AgentStartEvent;
@@ -383,6 +387,74 @@ void testRuntimeContextRequestHelpers() {
assertThrowsUnsupportedOperation(() -> request.getQueryParams().put("x", List.of("y")));
}
+ @Test
+ @DisplayName("Should create AguiSnapshotStore bean when enabled (MVC parity)")
+ void testMvcSnapshotStoreBeanCreatedWhenEnabled() {
+ mvcContextRunner
+ .withPropertyValues("agentscope.agui.snapshot-store-enabled=true")
+ .run(
+ ctx -> {
+ assertEquals(1, ctx.getBeansOfType(AguiSnapshotStore.class).size());
+ AguiAdapterConfig config =
+ mvcConfig(ctx.getBean(AguiMvcController.class));
+ assertTrue(config.isSnapshotStoreEnabled());
+ assertNotNull(config.getSnapshotStore());
+ });
+ }
+
+ @Test
+ @DisplayName("Should not create AguiSnapshotStore bean by default (WebFlux)")
+ void testWebFluxSnapshotStoreBeanAbsentByDefault() {
+ webFluxContextRunner.run(
+ ctx -> {
+ assertEquals(0, ctx.getBeansOfType(AguiSnapshotStore.class).size());
+ AguiAdapterConfig config = webFluxConfig(ctx.getBean(AguiWebFluxHandler.class));
+ assertFalse(config.isSnapshotStoreEnabled());
+ });
+ }
+
+ @Test
+ @DisplayName("Should create AguiSnapshotStore bean when enabled (WebFlux)")
+ void testWebFluxSnapshotStoreBeanCreatedWhenEnabled() {
+ webFluxContextRunner
+ .withPropertyValues("agentscope.agui.snapshot-store-enabled=true")
+ .run(
+ ctx -> {
+ assertEquals(1, ctx.getBeansOfType(AguiSnapshotStore.class).size());
+ AguiAdapterConfig config =
+ webFluxConfig(ctx.getBean(AguiWebFluxHandler.class));
+ assertTrue(config.isSnapshotStoreEnabled());
+ assertNotNull(config.getSnapshotStore());
+ });
+ }
+
+ @Test
+ @DisplayName("Should wire snapshot store into the handler only when enabled")
+ void testWebFluxConnectWiringPresentOnlyWhenEnabled() {
+ // Disabled by default: the handler's processor has no snapshot store, so /connect
+ // returns the empty handshake and no snapshot is recorded.
+ webFluxContextRunner.run(
+ ctx -> {
+ Object processor =
+ ReflectionTestUtils.getField(
+ ctx.getBean(AguiWebFluxHandler.class), "processor");
+ assertNull(ReflectionTestUtils.getField(processor, "snapshotStore"));
+ });
+
+ // Enabled: the handler's processor carries the snapshot store backing /connect hydrate.
+ webFluxContextRunner
+ .withPropertyValues("agentscope.agui.snapshot-store-enabled=true")
+ .run(
+ ctx -> {
+ Object processor =
+ ReflectionTestUtils.getField(
+ ctx.getBean(AguiWebFluxHandler.class), "processor");
+ Object store = ReflectionTestUtils.getField(processor, "snapshotStore");
+ assertNotNull(store);
+ assertInstanceOf(AguiSnapshotStore.class, store);
+ });
+ }
+
private static AguiAdapterConfig mvcConfig(AguiMvcController controller) {
Object processor = ReflectionTestUtils.getField(controller, "processor");
return (AguiAdapterConfig) ReflectionTestUtils.getField(processor, "config");
diff --git a/docs/v2/en/integration/protocol/agui.md b/docs/v2/en/integration/protocol/agui.md
index 750c72362c..5d624129bc 100644
--- a/docs/v2/en/integration/protocol/agui.md
+++ b/docs/v2/en/integration/protocol/agui.md
@@ -221,6 +221,9 @@ agentscope:
emit-run-finished-after-error: false
server-side-memory: false
interrupt-on-disconnect: true
+ # Presentation snapshot store (both off by default)
+ snapshot-store-enabled: false
+ snapshot-max-threads: 1000
```
`interrupt-on-disconnect` controls whether an Agent run is interrupted when the MVC/WebFlux SSE
@@ -333,6 +336,59 @@ For permission confirmations, `payload.approved` must be the boolean `true` to a
The front end does not need to echo `metadata` in `resume[]`; it only sends `interruptId`, `status`, and `payload`. Through the Spring `AguiRequestProcessor` entry point, AgentScope Java records the latest `RUN_FINISHED.outcome.interrupts[]` server-side, validates that the next `resume[]` covers all open interrupts, and passes the originating interrupts into the adapter for conversion.
+## Presentation Snapshot Store
+
+A reconnecting client needs to rebuild the visible conversation without re-running the agent. AgentScope Java provides a framework-level **presentation snapshot store** that materializes the AG-UI frames a browser should draw right now, and a read-only `POST {path-prefix}/connect` hydrate route that replays them.
+
+### Responsibility boundary
+
+| Component | Role | Mutated by hydrate? |
+| --- | --- | --- |
+| `AguiSnapshotStore` | Presentation state — derived, replayable, safe to lose. Answers "what should the browser draw right now". | No (read-only) |
+| `AgentStateStore` (core) + `AguiResumeCoordinator` | Authoritative state — agent context and the live human-in-the-loop contract. | No (read-only) |
+
+Because the snapshot only ever retains the **trailing unresolved** interrupt, a resolved historical interrupt cannot be revived on reconnect — the failure mode is removed at the data model instead of being filtered after the fact.
+
+### `/connect` frame contract
+
+`POST {path-prefix}/connect` accepts a `RunAgentInput` (only `threadId` / `runId` are used) and returns a read-only SSE stream. Hydrate is strictly read-only: it resolves no agent, mutates no resume coordinator, and creates no adapter. Frames are emitted in this exact order:
+
+```
+RUN_STARTED(threadId, runId)
+MESSAGES_SNAPSHOT(messages)
+STATE_SNAPSHOT(state) // omitted when state is empty
+ACTIVITY_SNAPSHOT(...) per ActivityFrame // omitted when none
+RUN_FINISHED(result=null, outcome=pendingOutcome)
+```
+
+A null `pendingOutcome` serializes as a plain successful run. An empty or missing snapshot produces the minimal three-frame handshake (`RUN_STARTED` → `MESSAGES_SNAPSHOT([])` → `RUN_FINISHED`).
+
+Only the trailing unresolved interrupt is replayed; when a new run starts the store drops any trailing interrupt, so a resolved interrupt can never reappear.
+
+### Configuration
+
+```yaml
+agentscope:
+ agui:
+ snapshot-store-enabled: false # set true to enable POST {path-prefix}/connect
+ snapshot-max-threads: 1000 # in-memory store capacity
+```
+
+Both keys are off by default, so existing clients stay byte-identical until you opt in. When enabled, the starter creates an in-memory `AguiSnapshotStore` bean and registers the `/connect` route (WebFlux `RouterFunction` / MVC `@PostMapping`).
+
+### Custom stores
+
+To persist snapshots externally (for example in Redis), implement `AguiSnapshotStore` and expose it as a bean — the starter's `@ConditionalOnMissingBean` defers to yours:
+
+```java
+@Bean
+AguiSnapshotStore aguiSnapshotStore(RedisTemplate redis) {
+ return new RedisAguiSnapshotStore(redis);
+}
+```
+
+The store API is intentionally tiny: `save`, `find`, `delete`, and a `clearPendingInterrupts` default that drops the trailing interrupt. Snapshots are immutable records, so a custom store can serialize them with any Jackson-compatible codec.
+
## Example Project
See the complete example at [agentscope-examples/agui](https://github.com/agentscope-ai/agentscope-java/tree/main/agentscope-examples/agui):
diff --git a/docs/v2/zh/integration/protocol/agui.md b/docs/v2/zh/integration/protocol/agui.md
index 2101561b55..bfe75f95c0 100644
--- a/docs/v2/zh/integration/protocol/agui.md
+++ b/docs/v2/zh/integration/protocol/agui.md
@@ -221,6 +221,9 @@ agentscope:
emit-run-finished-after-error: false
server-side-memory: false
interrupt-on-disconnect: true
+ # 展示快照存储(默认均关闭)
+ snapshot-store-enabled: false
+ snapshot-max-threads: 1000
```
`interrupt-on-disconnect` 用于控制 MVC/WebFlux 的 SSE 连接关闭、超时或发送事件失败时是否中断
@@ -332,6 +335,59 @@ AG-UI 前端可以在 `RunAgentInput.tools` 中传入工具 schema。adapter 会
前端不需要在 `resume[]` 中回传 `metadata`;只需要发送 `interruptId`、`status` 和 `payload`。通过 Spring `AguiRequestProcessor` 入口时,AgentScope Java 会在服务端记录最近一次 `RUN_FINISHED.outcome.interrupts[]`,校验下一次 `resume[]` 是否覆盖所有 open interrupts,并把原始 interrupt 传给 adapter 做恢复转换。
+## 展示快照存储
+
+重连的客户端需要在不重新运行 agent 的前提下重建可见会话。AgentScope Java 提供了框架级的**展示快照存储**,物化浏览器当前应渲染的 AG-UI 帧,并通过只读的 `POST {path-prefix}/connect` 水合路由重放它们。
+
+### 职责边界
+
+| 组件 | 角色 | 水合是否修改? |
+| --- | --- | --- |
+| `AguiSnapshotStore` | 展示状态——派生、可重放、可丢失。回答“浏览器现在应渲染什么”。 | 否(只读) |
+| `AgentStateStore`(核心)+ `AguiResumeCoordinator` | 权威状态——agent 上下文与活跃的人机交互契约。 | 否(只读) |
+
+由于快照只保留**最后一个未解决的 interrupt**,已解决的历史 interrupt 不可能在重连时复活——这一故障模式在数据模型层被消除,而非事后过滤。
+
+### `/connect` 帧契约
+
+`POST {path-prefix}/connect` 接收 `RunAgentInput`(仅使用 `threadId` / `runId`),返回只读 SSE 流。水合是严格只读的:不解析 agent、不修改 resume coordinator、不创建 adapter。帧按如下顺序发送:
+
+```
+RUN_STARTED(threadId, runId)
+MESSAGES_SNAPSHOT(messages)
+STATE_SNAPSHOT(state) // state 为空时省略
+ACTIVITY_SNAPSHOT(...) 每个 ActivityFrame // 没有时省略
+RUN_FINISHED(result=null, outcome=pendingOutcome)
+```
+
+`pendingOutcome` 为 null 时序列化为普通成功运行。空或缺失的快照返回最小三帧握手(`RUN_STARTED` → `MESSAGES_SNAPSHOT([])` → `RUN_FINISHED`)。
+
+只重放最后一个未解决的 interrupt;新 run 开始时存储会丢弃残留 interrupt,因此已解决的 interrupt 永不会再次出现。
+
+### 配置
+
+```yaml
+agentscope:
+ agui:
+ snapshot-store-enabled: false # 设为 true 以启用 POST {path-prefix}/connect
+ snapshot-max-threads: 1000 # 内存存储容量
+```
+
+两个键默认关闭,因此在显式开启前现有客户端保持字节级一致。启用后,starter 会创建内存版 `AguiSnapshotStore` bean 并注册 `/connect` 路由(WebFlux `RouterFunction` / MVC `@PostMapping`)。
+
+### 自定义存储
+
+如需将快照持久化到外部(例如 Redis),实现 `AguiSnapshotStore` 并暴露为 bean——starter 的 `@ConditionalOnMissingBean` 会优先使用你提供的:
+
+```java
+@Bean
+AguiSnapshotStore aguiSnapshotStore(RedisTemplate redis) {
+ return new RedisAguiSnapshotStore(redis);
+}
+```
+
+存储 API 刻意保持极简:`save`、`find`、`delete`,以及一个丢弃残留 interrupt 的 `clearPendingInterrupts` 默认实现。快照是不可变 record,自定义存储可用任意 Jackson 兼容 codec 序列化。
+
## 示例项目
完整示例见 [agentscope-examples/agui](https://github.com/agentscope-ai/agentscope-java/tree/main/agentscope-examples/agui):