Skip to content

fix(agui): suppress resolved historical interrupts on connect replay (#2911) - #2922

Open
chang6666 wants to merge 4 commits into
agentscope-ai:mainfrom
chang6666:fix/2911-copilotkit-replay-interrupt
Open

chang6666 wants to merge 4 commits into
agentscope-ai:mainfrom
chang6666:fix/2911-copilotkit-replay-interrupt

Conversation

@chang6666

@chang6666 chang6666 commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Fix #2911

Connect replay (/agent/{agentId}/connect) re-projects stored AgentEvents through the same converters, so historical RequireUserConfirmEvents that a later run already resolved were converted back into RunFinishedInterruptOutcome, re-activating the frontend interrupt form and leaving the suspended tool rendered as Running instead of Done.

Changes

  • replay() post-processes projected AG-UI events via suppressResolvedInterrupts(...).
  • Only the last run's interrupt outcome is preserved (an unresolved interrupt at thread end stays restorable after reload); interrupt outcomes of all earlier runs are replaced with null, which serializes as a normal successful run.
  • The last run is detected from the final event's run id of any kind, so a still-running continuation run also suppresses a resolved historical interrupt.
  • Dangling suspended tool calls of suppressed runs (TOOL_CALL_START without TOOL_CALL_RESULT) are closed with a synthetic TOOL_CALL_RESULT before that run's RUN_FINISHED, so the frontend renders the tool as completed instead of stuck running.
  • First unit test for the module (AgentEventAguiReplayerTest, 11 cases): suppression, last-run-only preservation, success-outcome passthrough, serialization, in-flight continuation, and dangling-tool closure edge cases.

Verification

  • mvn -pl agentscope-examples/agentscope-copilotkit test (JDK 17, -Dfrontend.skip=true): 11/11 pass
  • spotless:check and git diff --check clean

@jujn

jujn commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

cc @dragondyt

…ntscope-ai#2920)

Framework-level presentation-state store so reconnecting clients rebuild
the visible conversation without re-running the agent; collapses the agentscope-ai#2911
copilotkit workaround into the data model (only the trailing unresolved
interrupt is ever retained, so a resolved historical interrupt cannot
reappear on reconnect).

- store/: AguiJsonPatch (RFC 6902 applier), AguiThreadSnapshot,
  AguiSnapshotStore + InMemoryAguiSnapshotStore, AguiSnapshotAccumulator
  (absorbs resolved-interrupt suppression + dangling-tool synthesis),
  SnapshotRecordingEnricher (last observer, flush safety net),
  AguiSnapshotHydrator (read-only replay; empty -> 3-frame handshake)
- AguiActivityConverter + shared AguiJsonDiff helper (mirrors state converter)
- AguiAdapterConfig snapshot-store-enabled/snapshotStore (default off);
  AguiRequestProcessor.hydrate(), clearPendingInterrupts at beginRun,
  flush in doFinally
- starters: gated AguiSnapshotStore bean + POST {path-prefix}/connect route
  (WebFlux RouterFunction + MVC conditional AguiConnectController)
- copilotkit: migrate /connect to framework hydrator, delete
  suppressResolvedInterrupts/danglingToolCalls + obsolete test, enable store
- docs (en/zh): Presentation Snapshot Store section + YAML sample
- tests: store package, activity converter, config/processor/starter wiring
  and an end-to-end process-records -> hydrate-replays case
@dragondyt

Copy link
Copy Markdown
Contributor

The #2911 reconnect bug is clear, but this PR now includes two different changes.
The first two commits fix replay in CopilotKit. 263f14b adds a framework snapshot store, /connect hydrate, activity converter, starter wiring, and docs — that's a feature, not part of the bugfix.
Please keep this PR focused on #2911 and move the snapshot-store / hydrate work to a separate branch and PR. That will be much easier to review and land independently.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Replaces CopilotKit's event-log replay on connect with a framework-level AG-UI presentation snapshot store (AguiSnapshotStore / AguiSnapshotAccumulator / AguiSnapshotHydrator), so a resolved historical interrupt cannot be revived on reconnect and dangling tool calls are closed with a synthetic result. The direction is sound and the 9 new test classes are well above this repo's average. The findings below are about concurrency, unbounded state, and scope rather than about the fix being wrong — leaving as COMMENT for maintainer judgement.

Findings

  • [Warning] AguiSnapshotStore.java:68 — default clearPendingInterrupts is a non-atomic read-modify-write that can resurrect the trailing interrupt it is meant to clear.
  • [Warning] SnapshotRecordingEnricher.java:44 — accumulators is unbounded and a leaked entry retains the whole thread transcript, not just one run.
  • [Warning] SnapshotRecordingEnricher.java:76 — AguiSnapshotAccumulator uses plain LinkedHashMaps while the enricher is documented as reachable from two conversion paths.
  • [Warning] AguiRequestProcessor.java:246 — the "unknown" threadId fallback becomes a shared store key for reconnect.
  • [Info] InMemoryAguiSnapshotStore.java:77 — full-map rescan (plus retries) on every save once at capacity.
  • [Info] CopilotKitRuntimeService.java:125 — with the default snapshot-store-enabled=false, connect now returns an empty handshake where it used to replay persisted history.
  • [Info] AguiConnectController.java:88 — parser message echoed to the client, SSE frames assembled by hand.

Scope notes (no anchor line)

  1. Title/description vs. implementation. The description still presents this as a CopilotKit fix that post-processes replay() via suppressResolvedInterrupts(...) and adds an AgentEventAguiReplayerTest — neither exists in the diff (replay now serves only /threads/{id}/events, and the suppression moved into the framework accumulator). Refreshing the description would keep the rationale readable in history. The CopilotKit-side regression test for #2911 is also gone; the new store tests cover the mechanism, but not the reported user-visible flow.
  2. API surface. This adds a public SPI (AguiSnapshotStore plus 6 new public types), two starter properties, and a new POST {path-prefix}/connect route in both MVC and WebFlux — a considerably wider blast radius than fix(agui) suggests. Per the repo's review focus ("changes to core interfaces cascade to harness, distribution and all extensions"), splitting the framework store from the CopilotKit fix, or relabelling as feat(agui), would give the interface addition its own review.
  3. Durability. The only implementation is in-memory and per-node, so hydrate cannot serve a reconnect after a restart or behind horizontal scaling (a stated goal of agentscope-distribution). "Presentation-only, safe to lose" is a fair framing; a sentence in docs/v2/{en,zh}/integration/protocol/agui.md on the multi-instance limitation would set expectations for anyone writing a durable implementation.

Verification asked

mvn -pl agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui test plus both affected starters (agentscope-agui-spring-boot-starter) — the description reports only the CopilotKit module run.


Automated review by github-manager-bot

* @param threadId the thread id
*/
default void clearPendingInterrupts(String threadId) {
find(threadId)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearPendingInterrupts is a non-atomic read-modify-write (find -> filter -> save). If a run for the same thread reaches its terminal frame concurrently, the older snapshot written here can overwrite the freshly materialized one, which resurrects exactly the trailing RunFinishedInterruptOutcome this PR exists to remove. An atomic SPI (compute(threadId, UnaryOperator<AguiThreadSnapshot>), which ConcurrentHashMap gives for free) would close the window; at minimum the interface javadoc should state that implementations must serialize this transition.


private final AguiSnapshotStore store;
private final ConcurrentMap<String, AguiSnapshotAccumulator> accumulators =
new ConcurrentHashMap<>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This map is bounded only by traffic: an entry is dropped on a terminal frame or by an explicit flush(threadId, runId). A run that ends before either path is reached (transport error outside the enricher, a converter path that never emits the terminal frame, a cancellation that skips the doFinally hook) leaks its accumulator forever — and because each accumulator is seeded from store.find(threadId), the leaked object holds the whole thread transcript, not just one run. Could you bound it (max in-flight entries, or a TTL sweep alongside snapshotMaxThreads)?

threadId, runId, store.find(threadId).orElse(null)));
for (AguiEvent event : events) {
accumulator.consume(event);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AguiSnapshotAccumulator is built on plain LinkedHashMaps with no synchronization, and the class javadoc says the enricher is reached from both AgentEventConverterRegistry.convert() and the framework enrich(null, finishPendingEvents, ctx) path. If those two can run on different threads for the same threadId:runId, consume races on the same instance (lost deltas, or ConcurrentModificationException during materialize). If single-threaded delivery is guaranteed by the caller, please say so here; otherwise a synchronized (accumulator) around consume/materialize is cheap insurance.


private void evictIfOverCapacity() {
while (snapshots.size() > maxThreads) {
Map.Entry<String, AguiThreadSnapshot> oldest = null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once at capacity every save(...) scans the whole map to find the oldest entry, and the conditional remove(key, value) below can fail when a concurrent writer replaced that entry, so the while loop rescans. At the default snapshotMaxThreads=1000 that is a 1000-entry scan (plus retries) per completed run, on the request path. Sampling a few entries, keeping a separate ordering index, or evicting only when a size check wins would all be cheaper.

String threadId = input != null ? input.getThreadId() : null;
String runId = input != null ? input.getRunId() : null;
if (threadId == null) {
threadId = "unknown";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Substituting the literal "unknown" for a missing threadId and then querying the store with it makes every request that omits threadId share one bucket; if any run is ever recorded under that same fallback key, a later threadId-less connect from a different client hydrates someone else's conversation. Hydrate is read-only and has no agent to resolve, so rejecting the request (or returning the empty handshake without touching the store) is safer than inventing a key.

events.add(new AguiEvent.RunFinished(threadId, runId));
return events;
AguiSnapshotStore store = snapshotStoreProvider.getIfAvailable();
AguiThreadSnapshot snapshot = store != null ? store.find(threadId).orElse(null) : null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the new default (snapshot-store-enabled=false) store is null here, so connect always answers with the three-frame empty handshake where the previous implementation replayed persisted AgentEvents. That is intentional, but it is a user-visible behaviour change for anyone upgrading an existing deployment: reconnect silently loses history instead of restoring it. Worth a line in the docs next to the property, and a one-time startup log when connect is registered without a store.

public ResponseEntity<String> handleParseError(JsonException error) {
String errorEvent =
encoder.encodeToJson(
new AguiEvent.Raw(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small points on this error path: error.getMessage() is echoed straight to the caller (Jackson messages can carry payload fragments and internal type names — a generic client message plus a server-side log is safer), and the SSE frames are assembled by hand, so a message containing a newline breaks frame framing. Reusing encoder.encodeToJson framing for both events, as the happy path does, keeps the error response consistent.

@oss-maintainer

Copy link
Copy Markdown
Collaborator

⚠️ Merge conflict detected

This PR currently conflicts with main and cannot be merged (head fix/2911-copilotkit-replay-interrupt). Please rebase (or merge main in) and resolve the conflicts:

git fetch upstream
git checkout fix/2911-copilotkit-replay-interrupt
git rebase upstream/main
# resolve conflicts, then:
git push --force-with-lease

Once conflicts are resolved, ping @oss-maintainer for a re-review if the head also moves. This is a one-time reminder.


Automated notification by github-manager-bot

@chcodex

chcodex commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

@chang6666 这块还有继续推进吗

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]:agentscope-copilotkit中connect历史消息重放时重复激活前端中断UI

5 participants