Skip to content

Commit 7afdd16

Browse files
authored
Merge branch 'main' into fix/ENT-658-sdk-thread-tool-roundtrip
2 parents a879b8a + f02b83c commit 7afdd16

3 files changed

Lines changed: 258 additions & 3 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import React from "react";
2+
import { render, act } from "@testing-library/react";
3+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4+
import type { AbstractAgent } from "@ag-ui/client";
5+
import { useCopilotKit } from "../v2/context";
6+
import { useAgent } from "../v2/hooks/use-agent";
7+
import { CopilotChatConfigurationProvider } from "../v2/providers/CopilotChatConfigurationProvider";
8+
import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
9+
10+
vi.mock("../v2/context", () => ({
11+
useCopilotKit: vi.fn(),
12+
}));
13+
14+
const mockUseCopilotKit = useCopilotKit as ReturnType<typeof vi.fn>;
15+
16+
/**
17+
* Contract-level regression coverage for the threadId-propagation invariant.
18+
*
19+
* Lives at the package root (src/__tests__) rather than next to any one
20+
* implementation site on purpose: the previous regression (issue #5041, root
21+
* cause shared with #4739) slipped through because the original coverage
22+
* (use-agent-thread-isolation.test.tsx, 333 lines) lived next to the
23+
* per-thread-cloning feature and was deleted alongside it in May 2026 when
24+
* cloning was reverted. The threadId-propagation invariant survived the
25+
* feature change but its tests didn't.
26+
*
27+
* The invariant under test: a caller-supplied threadId (via <CopilotKit>,
28+
* <ThreadsProvider>, or <CopilotChatConfigurationProvider>) MUST end up on
29+
* the underlying agent's `threadId` field, because ProxiedCopilotRuntimeAgent
30+
* uses that field to address /agent/run, /agent/connect, /agent/stop. Without
31+
* it the agent ships its own auto-minted UUID and the backend sees a different
32+
* thread than the app code reads via useThreads/useCopilotChatConfiguration.
33+
*
34+
* This file should outlive any specific implementation strategy (cloning,
35+
* effect-based sync, prop drilling, context bridge). If you change the
36+
* mechanism, keep this contract.
37+
*/
38+
describe("useAgent → agent.threadId sync from chat configuration", () => {
39+
let mockCopilotkit: {
40+
getAgent: ReturnType<typeof vi.fn>;
41+
runtimeUrl: string | undefined;
42+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
43+
runtimeTransport: string;
44+
headers: Record<string, string>;
45+
agents: Record<string, AbstractAgent>;
46+
subscribeToAgentWithOptions: (
47+
agent: AbstractAgent,
48+
subscriber: any,
49+
) => { unsubscribe: () => void };
50+
};
51+
52+
beforeEach(() => {
53+
mockCopilotkit = {
54+
getAgent: vi.fn(() => undefined),
55+
runtimeUrl: "http://localhost:3000/api/copilotkit",
56+
runtimeConnectionStatus:
57+
CopilotKitCoreRuntimeConnectionStatus.Disconnected,
58+
runtimeTransport: "rest",
59+
headers: {},
60+
agents: {},
61+
subscribeToAgentWithOptions: (agent, subscriber) =>
62+
agent.subscribe(subscriber),
63+
};
64+
65+
mockUseCopilotKit.mockReturnValue({
66+
copilotkit: mockCopilotkit,
67+
executingToolCallIds: new Set(),
68+
});
69+
});
70+
71+
afterEach(() => {
72+
vi.restoreAllMocks();
73+
});
74+
75+
it("sets agent.threadId from CopilotChatConfigurationProvider when explicit", () => {
76+
// The agent's threadId is mutable state outside React's render tracking,
77+
// so we assert against the captured reference rather than rendered DOM —
78+
// mutating agent.threadId after commit doesn't trigger a re-render, but
79+
// downstream consumers (HTTP run/connect/stop, syncDelegate) read it
80+
// imperatively at call time, which is the actual contract the fix repairs.
81+
const callerThreadId = "caller-supplied-thread-id";
82+
let capturedAgent: AbstractAgent | null = null;
83+
84+
function Probe() {
85+
const { agent } = useAgent({ agentId: "test-agent" });
86+
capturedAgent = agent;
87+
return null;
88+
}
89+
90+
render(
91+
<CopilotChatConfigurationProvider threadId={callerThreadId}>
92+
<Probe />
93+
</CopilotChatConfigurationProvider>,
94+
);
95+
96+
expect(capturedAgent).not.toBeNull();
97+
expect(capturedAgent!.threadId).toBe(callerThreadId);
98+
});
99+
100+
it("does NOT overwrite the auto-minted threadId when hasExplicitThreadId is false", () => {
101+
// Mirrors the v1 CopilotKit bridge: ThreadsProvider auto-mints a UUID, the
102+
// bridge forwards it but marks it non-explicit so downstream consumers
103+
// don't treat the placeholder as a real backend thread.
104+
const placeholderThreadId = "placeholder-from-threads-provider";
105+
let capturedAgent: AbstractAgent | null = null;
106+
107+
function Probe() {
108+
const { agent } = useAgent({ agentId: "test-agent" });
109+
capturedAgent = agent;
110+
return null;
111+
}
112+
113+
render(
114+
<CopilotChatConfigurationProvider
115+
threadId={placeholderThreadId}
116+
hasExplicitThreadId={false}
117+
>
118+
<Probe />
119+
</CopilotChatConfigurationProvider>,
120+
);
121+
122+
expect(capturedAgent).not.toBeNull();
123+
expect(capturedAgent!.threadId).not.toBe(placeholderThreadId);
124+
// AbstractAgent's constructor auto-mints a UUID; just confirm it's a
125+
// non-empty string distinct from the placeholder.
126+
expect(capturedAgent!.threadId).toBeTruthy();
127+
});
128+
129+
it("re-syncs agent.threadId when the configuration's threadId changes", () => {
130+
let capturedAgent: AbstractAgent | null = null;
131+
132+
function Probe() {
133+
const { agent } = useAgent({ agentId: "test-agent" });
134+
capturedAgent = agent;
135+
return null;
136+
}
137+
138+
const { rerender } = render(
139+
<CopilotChatConfigurationProvider threadId="first-thread">
140+
<Probe />
141+
</CopilotChatConfigurationProvider>,
142+
);
143+
144+
expect(capturedAgent!.threadId).toBe("first-thread");
145+
146+
act(() => {
147+
rerender(
148+
<CopilotChatConfigurationProvider threadId="second-thread">
149+
<Probe />
150+
</CopilotChatConfigurationProvider>,
151+
);
152+
});
153+
154+
// Same agent instance (provisional cache keeps reference stable), updated threadId
155+
expect(capturedAgent!.threadId).toBe("second-thread");
156+
});
157+
158+
it("is a no-op when no CopilotChatConfigurationProvider is in scope", () => {
159+
// Headless / pure-v2 use without any configuration provider — useAgent
160+
// should leave the agent's auto-minted threadId alone instead of throwing.
161+
let capturedAgent: AbstractAgent | null = null;
162+
163+
function Probe() {
164+
const { agent } = useAgent({ agentId: "test-agent" });
165+
capturedAgent = agent;
166+
return null;
167+
}
168+
169+
expect(() => render(<Probe />)).not.toThrow();
170+
expect(capturedAgent).not.toBeNull();
171+
expect(capturedAgent!.threadId).toBeTruthy();
172+
});
173+
});

packages/react-core/src/components/copilot-provider/__tests__/v1-explicit-threadid-bridge.test.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { render, screen, act } from "@testing-library/react";
33
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
44
import { CopilotKit } from "../copilotkit";
55
import { useCopilotContext } from "../../../context/copilot-context";
6-
import { useCopilotChatConfiguration } from "../../../v2";
6+
import { useCopilotChatConfiguration, useAgent } from "../../../v2";
77
import type { CopilotKitProps } from "../copilotkit-props";
8+
import type { AbstractAgent } from "@ag-ui/client";
89

910
/**
1011
* Probe that reads hasExplicitThreadId from the CopilotChatConfigurationProvider
@@ -105,3 +106,66 @@ describe("v1 <CopilotKit> bridge → hasExplicitThreadId", () => {
105106
expect(screen.getByTestId("explicit").textContent).toBe("true");
106107
});
107108
});
109+
110+
/**
111+
* Regression coverage for issue #5041 (and #4739) at the customer-facing
112+
* surface: a threadId prop on <CopilotKit> must end up on the underlying
113+
* agent so that ProxiedCopilotRuntimeAgent ships it in /agent/run,
114+
* /agent/connect, /agent/stop. The cloning-revert in May 2026 broke this
115+
* for headless useAgent and V1 chat; CopilotChatConfigurationProvider got
116+
* the value but useAgent never copied it down onto agent.threadId.
117+
*/
118+
describe("v1 <CopilotKit> → useAgent → agent.threadId", () => {
119+
let warnSpy: ReturnType<typeof vi.spyOn>;
120+
beforeEach(() => {
121+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
122+
});
123+
afterEach(() => {
124+
warnSpy.mockRestore();
125+
});
126+
127+
function AgentProbe({
128+
onCapture,
129+
}: {
130+
onCapture: (agent: AbstractAgent) => void;
131+
}) {
132+
const { agent } = useAgent({ agentId: "default" });
133+
React.useEffect(() => {
134+
onCapture(agent);
135+
});
136+
return null;
137+
}
138+
139+
it("sets agent.threadId to the explicit threadId from the <CopilotKit> prop", () => {
140+
let captured: AbstractAgent | null = null;
141+
render(
142+
<CopilotKitAny publicApiKey="test-key" threadId="customer-thread-id">
143+
<AgentProbe onCapture={(agent) => (captured = agent)} />
144+
</CopilotKitAny>,
145+
);
146+
147+
expect(captured).not.toBeNull();
148+
expect((captured as unknown as AbstractAgent).threadId).toBe(
149+
"customer-thread-id",
150+
);
151+
});
152+
153+
it("leaves the agent's auto-minted threadId untouched when no threadId prop is supplied", () => {
154+
// Without an explicit threadId, the ThreadsProvider mints a placeholder
155+
// UUID that's marked non-explicit. The agent should keep its own
156+
// constructor-generated UUID rather than adopt the placeholder, so the
157+
// backend isn't asked to look up a thread it never created.
158+
let captured: AbstractAgent | null = null;
159+
render(
160+
<CopilotKitAny publicApiKey="test-key">
161+
<AgentProbe onCapture={(agent) => (captured = agent)} />
162+
</CopilotKitAny>,
163+
);
164+
165+
expect(captured).not.toBeNull();
166+
expect((captured as unknown as AbstractAgent).threadId).toBeTruthy();
167+
expect((captured as unknown as AbstractAgent).threadId).not.toBe(
168+
"mock-thread-id",
169+
);
170+
});
171+
});

packages/react-core/src/v2/hooks/use-agent.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { useCopilotKit } from "../context";
22
import { useMemo, useEffect, useReducer, useRef } from "react";
33
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
4-
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
4+
import type { AbstractAgent } from "@ag-ui/client";
5+
import { HttpAgent } from "@ag-ui/client";
56
import {
67
ProxiedCopilotRuntimeAgent,
78
CopilotKitCoreRuntimeConnectionStatus,
8-
type SubscribeToAgentSubscriber,
99
} from "@copilotkit/core";
10+
import type { SubscribeToAgentSubscriber } from "@copilotkit/core";
11+
import { useCopilotChatConfiguration } from "../providers/CopilotChatConfigurationProvider";
1012

1113
export enum UseAgentUpdate {
1214
OnMessagesChanged = "OnMessagesChanged",
@@ -221,6 +223,22 @@ export function useAgent({ agentId, updates, throttleMs }: UseAgentProps = {}) {
221223
// eslint-disable-next-line react-hooks/exhaustive-deps
222224
}, [agent, JSON.stringify(copilotkit.headers)]);
223225

226+
// Propagate the caller-supplied threadId from the chat configuration onto
227+
// the agent. AbstractAgent's constructor auto-mints a UUID when no threadId
228+
// is passed, so without this sync the agent ships its own random UUID in
229+
// /agent/run, /agent/connect, /agent/stop — diverging from the threadId the
230+
// app code reads via useThreads/useCopilotChatConfiguration. Gated on
231+
// hasExplicitThreadId so a ThreadsProvider-minted placeholder UUID doesn't
232+
// overwrite the auto-minted agent UUID (both are random and useless to the
233+
// backend; the explicit gate keeps the agent's UUID stable across renders).
234+
const chatConfig = useCopilotChatConfiguration();
235+
const configThreadId = chatConfig?.threadId;
236+
const configHasExplicitThreadId = chatConfig?.hasExplicitThreadId;
237+
useEffect(() => {
238+
if (!configHasExplicitThreadId || !configThreadId) return;
239+
agent.threadId = configThreadId;
240+
}, [agent, configThreadId, configHasExplicitThreadId]);
241+
224242
return {
225243
agent,
226244
};

0 commit comments

Comments
 (0)