Files
copilotkit__copilotkit/.changeset/fix-thread-switch-state-reset.md
Dusty aa08743c7e fix(core): only reset agent state on actual thread switch (supersedes #4720)
When the chat's connect effect re-fires due to React effect-dep churn, it
calls copilotkit.connectAgent({ agent }) on the same thread again. The
RunHandler previously called agent.setMessages([]), agent.setState({}),
and (transitively) clearReconnectCursor on every such call. That forced
the realtime gateway to replay the topic's full event history on every
churn re-connect — sending the same persisted cpki_event_ids 2-3 times
per thread switch — and produced both halves of Tyler's bug: duplicate
rows in the inspector AG-UI Events tab plus "Message not found" toasts
when the next runAgent fired with an empty agent.messages.

This change makes the orchestrator detect actual thread switches:

- RunHandler tracks _lastConnectedThreadId across connectAgent calls.
  On a fresh restore (different threadId from last call) it does the
  reset and clears the replay cursor. On churn (same threadId) it
  skips the reset entirely so local messages/state are preserved and
  the gateway resumes from lastSeenEventId instead of replaying.

- IntelligenceAgent.connect() no longer auto-clears the cursor;
  cursor management is the caller's decision now. clearReconnectCursor
  is made public so RunHandler (and tests) can call it explicitly.

- ProxiedCopilotRuntimeAgent exposes a clearReplayCursor(threadId)
  method that delegates to the IntelligenceAgent. Non-Intelligence
  runtime modes are a safe no-op.

This supersedes #4720, which attempted to solve only the inspector
duplicate-row symptom by adding a dispatcher dedup. That approach
introduced a blocking regression in the A → B → A restore path: the
dedup persisted across thread switches and suppressed the gateway's
restore replay, leaving agent.messages at [] and triggering the very
"Message not found" toast we were trying to prevent. Mike's review
caught it. The dispatcher dedup is removed entirely; the
orchestrator-level gate is sufficient and a smaller surface change.

Tests:
- intelligence-agent.test.ts: updated 4 tests that codified the old
  "always clear cursor" semantics. Added an explicit test that
  clearReconnectCursor() empties the cursor for the next connect, and
  Mike's regression — replay the same cpki_event_id after A → B → A
  and verify the rehydrate still produces the user message.
- core-connect-thread-switch.test.ts (new): four tests for the
  RunHandler gate covering churn, single switch, A → B → A, and the
  no-clearReplayCursor non-Intelligence path.

All 430 core tests pass.
2026-05-08 14:31:49 -06:00

1.6 KiB

@copilotkit/core
@copilotkit/core
patch

fix(core): only reset agent state and replay cursor on actual thread switch

RunHandler.connectAgent previously called agent.setMessages([]) + agent.setState({}) and the IntelligenceAgent delegate's clearReconnectCursor on every connect, including effect-dep churn re-connects on the same thread. That forced the realtime gateway to replay the topic's full event history every time the chat re-opened a socket — which under React effect churn happens 3-5 times per thread switch — and produced both halves of Tyler's bug: duplicate cpki_event_id rows in the inspector and intermittent "Message not found" toasts.

This change tracks the most recent connected threadId on RunHandler and gates the state-reset + cursor-clear on that threadId actually changing. Same-thread churn re-connects now preserve local messages/state and the gateway can resume from lastSeenEventId. Actual thread switches still wipe local state and ask for a full historical replay.

Supersedes #4720, which attempted to solve only the inspector duplicate-row symptom by adding a dispatcher dedup but introduced a regression in the A → B → A restore path (the dedup blocked the gateway's restore replay for thread A on second visit). The dispatcher dedup is removed entirely; the orchestrator-level gate is sufficient.

Adds focused unit tests covering: same-thread churn does not reset, cross-thread switch does reset, A → B → A resets each transition, and a regression test that replays the same cpki_event_id after a thread switch and verifies rehydrate still works.