Skip to content

Commit d60285c

Browse files
authored
fix(react-core): preserve generated thread tool followups (CopilotKit#5043)
## Summary - keep `CopilotChat` agents aligned to SDK-generated thread IDs even when `/connect` is intentionally skipped for non-explicit threads - stabilize `CopilotKitProvider` default object props so rerenders do not re-sync an empty local agent registry and replace the live remote/Intelligence agent mid-run - add regression coverage for SDK-generated thread frontend-tool follow-up runs and provider empty-agent rerender stability - add a focused langgraph-python showcase demo, aimock fixture, Playwright smoke, and QA checklist for ENT-658 - add a patch changeset for `@copilotkit/react-core` ## Testing - `npx nx run @copilotkit/react-core:test -- src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx` - `npx nx run @copilotkit/react-core:test -- src/v2/providers/__tests__/CopilotKitProvider.stability.test.tsx` - Pre-commit hook passed: `pnpm run test` and `pnpm run check:packages` - Verified exact `CopilotKit/Intelligence` repro branch `mme/threadid-repro`: unchecked `Explicit threadId`, sent `invoke testFrontendToolCalling with label X`, confirmed user message/tool card/assistant reply remain visible - Verified the same Intelligence repro with `Explicit threadId` checked - `pnpm exec playwright test tests/e2e/threadid-frontend-tool-roundtrip.spec.ts --project=chromium --workers=1` from `showcase/integrations/langgraph-python` ## QA Checklist - [x] Reproduce the reset in `CopilotKit/Intelligence` branch `mme/threadid-repro` with `Explicit threadId` unchecked - [x] Confirm generated-thread frontend-tool round-trip preserves the user message, tool card, and assistant response - [x] Confirm explicit-thread frontend-tool round-trip still preserves the user message, tool card, and assistant response - [x] Open `/demos/threadid-frontend-tool-roundtrip` in the langgraph-python showcase demo - [x] Confirm `Explicit threadId` is unchecked and the chat starts in SDK-generated thread mode - [x] Send `invoke testFrontendToolCalling with label X` - [x] Confirm the user message remains visible - [x] Confirm the `testFrontendToolCalling` card remains visible and shows `label: X` plus `result: handled X` - [x] Confirm the assistant reply `Frontend tool finished for X.` appears - [x] Confirm the chat does not return to the empty state - [x] Repeat with `Explicit threadId` checked and confirm the explicit-thread path is unchanged ## Notes The visible reset had two frontend-side causes. First, the chat and agent could diverge when the SDK generated the thread ID. Second, in Intelligence mode, provider rerenders could re-sync an empty local agent registry and replace the live remote agent instance mid-run, dropping the in-memory chat stream. Both fixes live in `@copilotkit/react-core`. The Playwright file is intentionally a smoke test for the demo route/toggle. The source-level regressions live in `CopilotChat.absentThreadConnect.test.tsx` and `CopilotKitProvider.stability.test.tsx`.
2 parents b26272c + 8b62c97 commit d60285c

10 files changed

Lines changed: 391 additions & 38 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@copilotkit/react-core": patch
3+
---
4+
5+
fix: keep SDK-generated chat threads active across frontend tool follow-up runs

packages/react-core/src/v2/components/chat/CopilotChat.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { useAgent } from "../../hooks/use-agent";
22
import { useAttachments } from "../../hooks/use-attachments";
33
import { useSuggestions } from "../../hooks/use-suggestions";
4-
import { CopilotChatView, CopilotChatViewProps } from "./CopilotChatView";
5-
import { CopilotChatInputMode } from "./CopilotChatInput";
4+
import type { CopilotChatViewProps } from "./CopilotChatView";
5+
import { CopilotChatView } from "./CopilotChatView";
6+
import type { CopilotChatInputMode } from "./CopilotChatInput";
7+
import type { CopilotChatLabels } from "../../providers/CopilotChatConfigurationProvider";
68
import {
79
CopilotChatConfigurationProvider,
8-
CopilotChatLabels,
910
useCopilotChatConfiguration,
1011
} from "../../providers/CopilotChatConfigurationProvider";
1112
import {
@@ -14,7 +15,7 @@ import {
1415
TranscriptionErrorCode,
1516
} from "@copilotkit/shared";
1617
import type { AttachmentsConfig, InputContent } from "@copilotkit/shared";
17-
import { Suggestion, CopilotKitCoreErrorCode } from "@copilotkit/core";
18+
import type { Suggestion, CopilotKitCoreErrorCode } from "@copilotkit/core";
1819
import React, {
1920
useCallback,
2021
useEffect,
@@ -27,16 +28,16 @@ import {
2728
useLicenseContext,
2829
} from "../../providers/CopilotKitProvider";
2930
import { InlineFeatureWarning } from "../../components/license-warning-banner";
30-
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
31-
import { renderSlot, useShallowStableRef, SlotValue } from "../../lib/slots";
31+
import type { AbstractAgent } from "@ag-ui/client";
32+
import { HttpAgent } from "@ag-ui/client";
33+
import type { SlotValue } from "../../lib/slots";
34+
import { renderSlot, useShallowStableRef } from "../../lib/slots";
3235
import {
3336
transcribeAudio,
3437
TranscriptionError,
3538
} from "../../lib/transcription-client";
36-
import {
37-
LastUserMessageContext,
38-
type LastUserMessageState,
39-
} from "./last-user-message-context";
39+
import { LastUserMessageContext } from "./last-user-message-context";
40+
import type { LastUserMessageState } from "./last-user-message-context";
4041

4142
export type CopilotChatProps = Omit<
4243
CopilotChatViewProps,
@@ -216,6 +217,10 @@ export function CopilotChat({
216217
hasExplicitThreadId && lastConnectedThreadId !== resolvedThreadId;
217218

218219
useEffect(() => {
220+
// Non-explicit threads skip /connect, but the first runAgent still has to
221+
// ship the same SDK-generated threadId that the chat UI is rendering.
222+
agent.threadId = resolvedThreadId;
223+
219224
// When the caller hasn't picked a specific thread, resolvedThreadId is a
220225
// UUID minted locally (either in this CopilotChat or in a wrapping
221226
// ThreadsProvider). The backend has never seen it, so /connect would
@@ -234,11 +239,9 @@ export function CopilotChat({
234239
agent.abortController = connectAbortController;
235240
}
236241

237-
agent.threadId = resolvedThreadId;
238-
239-
const connect = async (agent: AbstractAgent) => {
242+
const connect = async (agentToConnect: AbstractAgent) => {
240243
try {
241-
await copilotkit.connectAgent({ agent });
244+
await copilotkit.connectAgent({ agent: agentToConnect });
242245
} catch (error) {
243246
// Ignore errors from aborted connections (e.g., React StrictMode cleanup)
244247
if (detached) return;

packages/react-core/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import React from "react";
22
import { describe, it, expect, vi } from "vitest";
3-
import { render } from "@testing-library/react";
3+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
44
import { EMPTY, Observable } from "rxjs";
5-
import { type BaseEvent, type RunAgentInput } from "@ag-ui/client";
6-
import { MockStepwiseAgent } from "../../../__tests__/utils/test-helpers";
5+
import { z } from "zod";
6+
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
7+
import {
8+
MockStepwiseAgent,
9+
runFinishedEvent,
10+
runStartedEvent,
11+
textChunkEvent,
12+
toolCallChunkEvent,
13+
} from "../../../__tests__/utils/test-helpers";
714
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
815
import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
16+
import { useFrontendTool } from "../../../hooks/use-frontend-tool";
917
import { CopilotChat } from "../CopilotChat";
18+
import type { ReactFrontendTool } from "../../../types";
1019

1120
describe("CopilotChat avoids /connect for locally-generated threadIds (ENT-314)", () => {
1221
function buildAgentWithConnectSpy(): {
@@ -47,6 +56,7 @@ describe("CopilotChat avoids /connect for locally-generated threadIds (ENT-314)"
4756

4857
await new Promise((resolve) => setTimeout(resolve, 50));
4958
expect(connectSpy).toHaveBeenCalled();
59+
expect(connectSpy.mock.calls[0][0].threadId).toBe("user-thread-abc");
5060
});
5161

5262
it("calls connect() when a threadId is supplied via configuration provider", async () => {
@@ -62,5 +72,112 @@ describe("CopilotChat avoids /connect for locally-generated threadIds (ENT-314)"
6272

6373
await new Promise((resolve) => setTimeout(resolve, 50));
6474
expect(connectSpy).toHaveBeenCalled();
75+
expect(connectSpy.mock.calls[0][0].threadId).toBe("config-thread-xyz");
76+
});
77+
78+
it("uses the SDK-generated threadId for frontend tool follow-up runs", async () => {
79+
class FrontendToolRoundTripAgent extends MockStepwiseAgent {
80+
runInputs: RunAgentInput[] = [];
81+
82+
run(input: RunAgentInput): Observable<BaseEvent> {
83+
this.runInputs.push(input);
84+
const runNumber = this.runInputs.length;
85+
86+
return new Observable<BaseEvent>((subscriber) => {
87+
queueMicrotask(() => {
88+
subscriber.next(runStartedEvent());
89+
if (runNumber === 1) {
90+
subscriber.next(
91+
textChunkEvent("assistant-tool", "Calling frontend tool."),
92+
);
93+
subscriber.next(
94+
toolCallChunkEvent({
95+
parentMessageId: "assistant-tool",
96+
delta: '{"label":"X"}',
97+
toolCallId: "tc-sdk-generated-thread",
98+
toolCallName: "testFrontendToolCalling",
99+
}),
100+
);
101+
} else {
102+
subscriber.next(
103+
textChunkEvent(
104+
"assistant-final",
105+
"Frontend tool finished for X.",
106+
),
107+
);
108+
}
109+
subscriber.next(runFinishedEvent());
110+
subscriber.complete();
111+
});
112+
});
113+
}
114+
}
115+
116+
function FrontendToolRegistration() {
117+
const frontendTool: ReactFrontendTool<{ label: string }> = {
118+
name: "testFrontendToolCalling",
119+
parameters: z.object({ label: z.string() }),
120+
followUp: true,
121+
handler: async ({ label }) => `handled ${String(label)}`,
122+
render: ({ args, result }) => (
123+
<div data-testid="frontend-tool-card">
124+
{String(args.label)}:{String(result ?? "pending")}
125+
</div>
126+
),
127+
};
128+
useFrontendTool(frontendTool);
129+
return null;
130+
}
131+
132+
const agent = new FrontendToolRoundTripAgent();
133+
134+
render(
135+
<CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
136+
<CopilotChatConfigurationProvider
137+
threadId="sdk-generated-thread"
138+
hasExplicitThreadId={false}
139+
>
140+
<FrontendToolRegistration />
141+
<CopilotChat welcomeScreen={false} />
142+
</CopilotChatConfigurationProvider>
143+
</CopilotKitProvider>,
144+
);
145+
146+
const input = await screen.findByRole("textbox");
147+
fireEvent.change(input, {
148+
target: {
149+
value: "invoke testFrontendToolCalling with label X",
150+
},
151+
});
152+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
153+
154+
await waitFor(() => {
155+
expect(agent.runInputs).toHaveLength(2);
156+
});
157+
158+
expect(agent.runInputs.map((runInput) => runInput.threadId)).toEqual([
159+
"sdk-generated-thread",
160+
"sdk-generated-thread",
161+
]);
162+
expect(agent.runInputs[0].messages).toEqual(
163+
expect.arrayContaining([
164+
expect.objectContaining({
165+
role: "user",
166+
content: "invoke testFrontendToolCalling with label X",
167+
}),
168+
]),
169+
);
170+
await waitFor(() => {
171+
expect(screen.getByTestId("frontend-tool-card").textContent).toBe(
172+
"X:handled X",
173+
);
174+
});
175+
expect(
176+
agent.messages.some(
177+
(message) =>
178+
message.role === "assistant" &&
179+
message.content === "Frontend tool finished for X.",
180+
),
181+
).toBe(true);
65182
});
66183
});

packages/react-core/src/v2/providers/CopilotKitProvider.tsx

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,25 @@ import type { AbstractAgent } from "@ag-ui/client";
44
import type { FrontendTool } from "@copilotkit/core";
55
import type React from "react";
66
import {
7-
type ReactNode,
87
useMemo,
98
useEffect,
109
useLayoutEffect,
1110
useReducer,
1211
useRef,
1312
useState,
1413
} from "react";
14+
import type { ReactNode } from "react";
1515
// Context extracted to ../context.ts for cross-platform reuse (React Native)
16-
import {
17-
CopilotKitContext,
18-
type CopilotKitContextValue,
19-
LicenseContext,
20-
} from "../context";
16+
import { CopilotKitContext, LicenseContext } from "../context";
17+
import type { CopilotKitContextValue } from "../context";
2118
export type { CopilotKitContextValue } from "../context";
2219
export { CopilotKitContext, useLicenseContext } from "../context";
2320
import { z } from "zod";
2421
import { CopilotKitInspector } from "../components/CopilotKitInspector";
2522
import type { Anchor } from "@copilotkit/web-inspector";
2623
import { LicenseWarningBanner } from "../components/license-warning-banner";
27-
import {
28-
createLicenseContextValue,
29-
type LicenseContextValue,
30-
type DebugConfig,
31-
} from "@copilotkit/shared";
24+
import { createLicenseContextValue } from "@copilotkit/shared";
25+
import type { LicenseContextValue, DebugConfig } from "@copilotkit/shared";
3226
import type { CopilotKitCoreErrorCode } from "@copilotkit/core";
3327
import {
3428
MCPAppsActivityContentSchema,
@@ -62,6 +56,11 @@ import { zodToJsonSchema } from "zod-to-json-schema";
6256

6357
const HEADER_NAME = "X-CopilotCloud-Public-Api-Key";
6458
const COPILOT_CLOUD_CHAT_URL = "https://api.cloud.copilotkit.ai/copilotkit/v1";
59+
// Stable frozen defaults keep provider effects from re-running just because a
60+
// caller omitted an object prop on a rerender.
61+
const EMPTY_HEADERS: Readonly<Record<string, string>> = Object.freeze({});
62+
const EMPTY_PROPERTIES: Readonly<Record<string, unknown>> = Object.freeze({});
63+
const EMPTY_AGENTS: Readonly<Record<string, AbstractAgent>> = Object.freeze({});
6564

6665
const DEFAULT_DESIGN_SKILL = `When generating UI with generateSandboxedUi, follow these design principles inspired by shadcn/ui:
6766
@@ -246,14 +245,14 @@ function useStableArrayProp<T>(
246245
export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
247246
children,
248247
runtimeUrl,
249-
headers: headersProp = {},
248+
headers: headersProp = EMPTY_HEADERS,
250249
credentials,
251250
publicApiKey,
252251
publicLicenseKey,
253252
licenseToken,
254-
properties = {},
255-
agents__unsafe_dev_only: agents = {},
256-
selfManagedAgents = {},
253+
properties = EMPTY_PROPERTIES,
254+
agents__unsafe_dev_only: agents = EMPTY_AGENTS,
255+
selfManagedAgents = EMPTY_AGENTS,
257256
renderToolCalls,
258257
renderActivityMessages,
259258
renderCustomMessages,

packages/react-core/src/v2/providers/__tests__/CopilotKitProvider.stability.test.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { z } from "zod";
55
import type { ReactFrontendTool } from "../../types/frontend-tool";
66
import type { ReactToolCallRenderer } from "../../types";
7-
import {
8-
CopilotKitProvider,
9-
useCopilotKit,
10-
type CopilotKitContextValue,
11-
} from "../CopilotKitProvider";
12-
import { CopilotKitCoreReact } from "../../lib/react-core";
7+
import { CopilotKitProvider, useCopilotKit } from "../CopilotKitProvider";
8+
import type { CopilotKitContextValue } from "../CopilotKitProvider";
9+
import type { CopilotKitCoreReact } from "../../lib/react-core";
1310
import { useFrontendTool } from "../../hooks/use-frontend-tool";
1411

1512
// Mock console methods to suppress expected warnings
@@ -149,6 +146,51 @@ describe("CopilotKitProvider stability", () => {
149146
});
150147

151148
describe("setter calls on prop changes", () => {
149+
it("preserves dynamically registered agents on unchanged rerenders", () => {
150+
const setAgentsSpy = vi.fn();
151+
let spyAttached = false;
152+
let registeredAgent: unknown;
153+
let capturedInstance: CopilotKitCoreReact | null = null;
154+
155+
function SpyAttacher() {
156+
const { copilotkit } = useCopilotKit();
157+
capturedInstance = copilotkit;
158+
if (!spyAttached) {
159+
const original =
160+
copilotkit.setAgents__unsafe_dev_only.bind(copilotkit);
161+
copilotkit.setAgents__unsafe_dev_only = (agents) => {
162+
setAgentsSpy(agents);
163+
return original(agents);
164+
};
165+
registeredAgent = copilotkit.registerProxiedAgent({
166+
agentId: "registered-after-mount",
167+
runtimeAgentId: "remote-agent",
168+
}).agent;
169+
spyAttached = true;
170+
}
171+
return null;
172+
}
173+
174+
const { rerender } = render(
175+
<CopilotKitProvider runtimeUrl="http://localhost:3000/api">
176+
<SpyAttacher />
177+
</CopilotKitProvider>,
178+
);
179+
180+
setAgentsSpy.mockClear();
181+
182+
rerender(
183+
<CopilotKitProvider runtimeUrl="http://localhost:3000/api">
184+
<SpyAttacher />
185+
</CopilotKitProvider>,
186+
);
187+
188+
expect(setAgentsSpy).not.toHaveBeenCalled();
189+
expect(capturedInstance?.getAgent("registered-after-mount")).toBe(
190+
registeredAgent,
191+
);
192+
});
193+
152194
it("calls setTools when frontendTools change instead of recreating instance", () => {
153195
const setToolsSpy = vi.fn();
154196
let spyAttached = false;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"_meta": {
3+
"description": "ENT-658 regression fixture for langgraph-python /demos/threadid-frontend-tool-roundtrip. First turn emits a frontend tool call; follow-up turn confirms the tool result without changing threads.",
4+
"created": "2026-05-27"
5+
},
6+
"fixtures": [
7+
{
8+
"match": {
9+
"userMessage": "invoke testFrontendToolCalling with label X",
10+
"toolCallId": "call_ent_658_test_frontend_tool_x"
11+
},
12+
"response": {
13+
"content": "Frontend tool finished for X."
14+
}
15+
},
16+
{
17+
"match": {
18+
"userMessage": "invoke testFrontendToolCalling with label X"
19+
},
20+
"response": {
21+
"content": "Calling frontend tool.",
22+
"toolCalls": [
23+
{
24+
"id": "call_ent_658_test_frontend_tool_x",
25+
"name": "testFrontendToolCalling",
26+
"arguments": {
27+
"label": "X"
28+
}
29+
}
30+
]
31+
}
32+
}
33+
]
34+
}

0 commit comments

Comments
 (0)