@copilotkit/react-core 1.55.1 → 1.55.2-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Tests for MCP Apps ui/message handler behavior.
3
+ *
4
+ * Verifies the followUp logic that controls whether the agent is invoked
5
+ * after an MCP app sends a ui/message request via JSON-RPC:
6
+ *
7
+ * shouldFollowUp = params.followUp ?? role === "user"
8
+ *
9
+ * - User-role messages invoke runAgent (default followUp = true)
10
+ * - Assistant-role messages do NOT invoke runAgent (default followUp = false)
11
+ * - followUp: false skips runAgent regardless of role
12
+ * - followUp: true forces runAgent regardless of role
13
+ * - addMessage is always called when textContent is present
14
+ */
15
+ import { fireEvent, screen, waitFor, act } from "@testing-library/react";
16
+ import { vi } from "vitest";
17
+ import {
18
+ activitySnapshotEvent,
19
+ renderWithCopilotKit,
20
+ runFinishedEvent,
21
+ runStartedEvent,
22
+ testId,
23
+ } from "../../../__tests__/utils/test-helpers";
24
+ import { MCPAppsActivityType } from "../../../components/MCPAppsActivityRenderer";
25
+ import {
26
+ AbstractAgent,
27
+ RunAgentInput,
28
+ RunAgentResult,
29
+ BaseEvent,
30
+ EventType,
31
+ } from "@ag-ui/client";
32
+ import { Observable, Subject } from "rxjs";
33
+
34
+ /**
35
+ * MockMCPProxyAgent with spying support for ui/message tests.
36
+ */
37
+ class MockMCPProxyAgent extends AbstractAgent {
38
+ private subject = new Subject<BaseEvent>();
39
+ public runAgentCalls: Array<{ input: Partial<RunAgentInput> }> = [];
40
+ public addMessageCalls: Array<{
41
+ id: string;
42
+ role: string;
43
+ content: string;
44
+ }> = [];
45
+
46
+ private runAgentResponses: Map<string, unknown> = new Map();
47
+
48
+ setRunAgentResponse(method: string, response: unknown) {
49
+ this.runAgentResponses.set(method, response);
50
+ }
51
+
52
+ emit(event: BaseEvent) {
53
+ if (event.type === EventType.RUN_STARTED) {
54
+ this.isRunning = true;
55
+ } else if (
56
+ event.type === EventType.RUN_FINISHED ||
57
+ event.type === EventType.RUN_ERROR
58
+ ) {
59
+ this.isRunning = false;
60
+ }
61
+ act(() => {
62
+ this.subject.next(event);
63
+ });
64
+ }
65
+
66
+ complete() {
67
+ this.isRunning = false;
68
+ act(() => {
69
+ this.subject.complete();
70
+ });
71
+ }
72
+
73
+ clone(): MockMCPProxyAgent {
74
+ const cloned = new MockMCPProxyAgent();
75
+ cloned.agentId = this.agentId;
76
+ type Internal = {
77
+ subject: Subject<BaseEvent>;
78
+ runAgentCalls: Array<{ input: Partial<RunAgentInput> }>;
79
+ addMessageCalls: Array<{ id: string; role: string; content: string }>;
80
+ runAgentResponses: Map<string, unknown>;
81
+ };
82
+ (cloned as unknown as Internal).subject = (
83
+ this as unknown as Internal
84
+ ).subject;
85
+ (cloned as unknown as Internal).runAgentCalls = (
86
+ this as unknown as Internal
87
+ ).runAgentCalls;
88
+ (cloned as unknown as Internal).addMessageCalls = (
89
+ this as unknown as Internal
90
+ ).addMessageCalls;
91
+ (cloned as unknown as Internal).runAgentResponses = (
92
+ this as unknown as Internal
93
+ ).runAgentResponses;
94
+
95
+ const registry = this;
96
+ Object.defineProperty(cloned, "isRunning", {
97
+ get() {
98
+ return registry.isRunning;
99
+ },
100
+ set(v: boolean) {
101
+ registry.isRunning = v;
102
+ },
103
+ configurable: true,
104
+ enumerable: true,
105
+ });
106
+
107
+ const proto = MockMCPProxyAgent.prototype;
108
+ cloned.runAgent = async function (
109
+ input?: Partial<RunAgentInput>,
110
+ ): Promise<RunAgentResult> {
111
+ const proxiedRequest = input?.forwardedProps?.__proxiedMCPRequest;
112
+ if (proxiedRequest) {
113
+ return registry.runAgent(input);
114
+ }
115
+ return proto.runAgent.call(cloned, input);
116
+ };
117
+
118
+ // Track addMessage calls on the clone (the component uses the clone)
119
+ const origAddMessage = cloned.addMessage.bind(cloned);
120
+ cloned.addMessage = function (msg: Parameters<typeof origAddMessage>[0]) {
121
+ registry.addMessageCalls.push(msg as any);
122
+ return origAddMessage(msg);
123
+ };
124
+
125
+ // Proxy run() calls so spies on the registry's run() see clone invocations
126
+ cloned.run = function (input: RunAgentInput): Observable<BaseEvent> {
127
+ return registry.run(input);
128
+ };
129
+
130
+ return cloned;
131
+ }
132
+
133
+ async detachActiveRun(): Promise<void> {}
134
+
135
+ run(_input: RunAgentInput): Observable<BaseEvent> {
136
+ return this.subject.asObservable();
137
+ }
138
+
139
+ async runAgent(input?: Partial<RunAgentInput>): Promise<RunAgentResult> {
140
+ const proxiedRequest = input?.forwardedProps?.__proxiedMCPRequest as
141
+ | {
142
+ serverHash?: string;
143
+ serverId?: string;
144
+ method: string;
145
+ params?: Record<string, unknown>;
146
+ }
147
+ | undefined;
148
+
149
+ if (proxiedRequest) {
150
+ if (input) {
151
+ this.runAgentCalls.push({ input });
152
+ }
153
+ const method = proxiedRequest.method;
154
+ const response = this.runAgentResponses.get(method);
155
+ if (response !== undefined) {
156
+ return { result: response, newMessages: [] };
157
+ }
158
+ if (method === "resources/read") {
159
+ return {
160
+ result: {
161
+ contents: [
162
+ {
163
+ uri: proxiedRequest.params?.uri,
164
+ mimeType: "text/html",
165
+ text: "<html><body>Test content</body></html>",
166
+ },
167
+ ],
168
+ },
169
+ newMessages: [],
170
+ };
171
+ }
172
+ return { result: {}, newMessages: [] };
173
+ }
174
+
175
+ return super.runAgent(input);
176
+ }
177
+ }
178
+
179
+ function mcpAppsActivityContent(overrides: {
180
+ resourceUri?: string;
181
+ serverHash?: string;
182
+ }) {
183
+ return {
184
+ resourceUri: overrides.resourceUri ?? "ui://test-server/test-resource",
185
+ serverHash: overrides.serverHash ?? "abc123hash",
186
+ toolInput: {},
187
+ result: {
188
+ content: [{ type: "text", text: "Tool output" }],
189
+ isError: false,
190
+ },
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Set up the agent, render, emit MCP activity, wait for iframe creation,
196
+ * then simulate sandbox-proxy-ready so the message handler gets installed.
197
+ */
198
+ async function setupMCPActivity(
199
+ agent: MockMCPProxyAgent,
200
+ agentId: string,
201
+ userMessage: string,
202
+ ): Promise<HTMLIFrameElement> {
203
+ agent.setRunAgentResponse("resources/read", {
204
+ contents: [
205
+ {
206
+ uri: "ui://test/app",
207
+ mimeType: "text/html",
208
+ text: "<html><body>App</body></html>",
209
+ },
210
+ ],
211
+ });
212
+
213
+ // Use a unique threadId per test to avoid module-level mcpAppsRequestQueue
214
+ // state leaking between tests (the queue keys by threadId).
215
+ const threadId = testId("thread");
216
+
217
+ renderWithCopilotKit({
218
+ agents: { [agentId]: agent },
219
+ agentId,
220
+ threadId,
221
+ });
222
+
223
+ const input = await screen.findByRole("textbox");
224
+ fireEvent.change(input, { target: { value: userMessage } });
225
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
226
+
227
+ await waitFor(() => {
228
+ expect(screen.getByText(userMessage)).toBeDefined();
229
+ });
230
+
231
+ agent.emit(runStartedEvent());
232
+ agent.emit(
233
+ activitySnapshotEvent({
234
+ messageId: testId("mcp-activity"),
235
+ activityType: MCPAppsActivityType,
236
+ content: mcpAppsActivityContent({
237
+ resourceUri: "ui://test/app",
238
+ serverHash: "test-hash",
239
+ }),
240
+ }),
241
+ );
242
+ agent.emit(runFinishedEvent());
243
+
244
+ // Wait for iframe to be created
245
+ let iframe: HTMLIFrameElement | null = null;
246
+ await waitFor(
247
+ () => {
248
+ iframe = document.querySelector("iframe[srcdoc]");
249
+ expect(iframe).not.toBeNull();
250
+ },
251
+ { timeout: 3000 },
252
+ );
253
+
254
+ // Simulate sandbox-proxy-ready notification from the iframe.
255
+ // The message handler checks event.source === iframe.contentWindow.
256
+ // In jsdom, iframe.contentWindow exists for srcdoc iframes.
257
+ const readyEvent = new MessageEvent("message", {
258
+ data: {
259
+ jsonrpc: "2.0",
260
+ method: "ui/notifications/sandbox-proxy-ready",
261
+ },
262
+ source: iframe!.contentWindow,
263
+ origin: "",
264
+ });
265
+
266
+ await act(async () => {
267
+ window.dispatchEvent(readyEvent);
268
+ // Give async setup() time to install the messageHandler
269
+ await new Promise((resolve) => setTimeout(resolve, 100));
270
+ });
271
+
272
+ return iframe!;
273
+ }
274
+
275
+ /**
276
+ * Send a ui/message JSON-RPC request as if coming from the iframe.
277
+ */
278
+ async function sendUiMessage(
279
+ iframe: HTMLIFrameElement,
280
+ params: {
281
+ role?: string;
282
+ content?: Array<{ type: string; text?: string }>;
283
+ followUp?: boolean;
284
+ },
285
+ ) {
286
+ const msg = new MessageEvent("message", {
287
+ data: {
288
+ jsonrpc: "2.0",
289
+ id: testId("req"),
290
+ method: "ui/message",
291
+ params,
292
+ },
293
+ source: iframe.contentWindow,
294
+ origin: "",
295
+ });
296
+
297
+ await act(async () => {
298
+ window.dispatchEvent(msg);
299
+ await new Promise((resolve) => setTimeout(resolve, 200));
300
+ });
301
+ }
302
+
303
+ describe("MCP Apps ui/message followUp behavior", () => {
304
+ beforeEach(() => {
305
+ vi.clearAllMocks();
306
+ });
307
+
308
+ it("user-role message: addMessage IS called and runAgent IS invoked", async () => {
309
+ const agent = new MockMCPProxyAgent();
310
+ agent.agentId = "ui-msg-agent-user";
311
+
312
+ const iframe = await setupMCPActivity(
313
+ agent,
314
+ "ui-msg-agent-user",
315
+ "User role test",
316
+ );
317
+
318
+ const runSpy = vi.spyOn(agent, "run");
319
+
320
+ await sendUiMessage(iframe, {
321
+ role: "user",
322
+ content: [{ type: "text", text: "Hello from MCP app" }],
323
+ });
324
+
325
+ // addMessage should have been called
326
+ const userMsgCalls = agent.addMessageCalls.filter(
327
+ (c) => c.content === "Hello from MCP app" && c.role === "user",
328
+ );
329
+ expect(userMsgCalls.length).toBeGreaterThanOrEqual(1);
330
+
331
+ // runAgent should have been invoked (user role defaults to followUp: true)
332
+ expect(runSpy.mock.calls.length).toBeGreaterThan(0);
333
+ });
334
+
335
+ it("assistant-role message: addMessage IS called but runAgent is NOT invoked", async () => {
336
+ const agent = new MockMCPProxyAgent();
337
+ agent.agentId = "ui-msg-agent-assist";
338
+
339
+ const iframe = await setupMCPActivity(
340
+ agent,
341
+ "ui-msg-agent-assist",
342
+ "Assist role test",
343
+ );
344
+
345
+ const runSpy = vi.spyOn(agent, "run");
346
+
347
+ await sendUiMessage(iframe, {
348
+ role: "assistant",
349
+ content: [{ type: "text", text: "Response from MCP" }],
350
+ });
351
+
352
+ // addMessage should have been called
353
+ const assistCalls = agent.addMessageCalls.filter(
354
+ (c) => c.content === "Response from MCP" && c.role === "assistant",
355
+ );
356
+ expect(assistCalls.length).toBeGreaterThanOrEqual(1);
357
+
358
+ // run() should NOT have been called
359
+ expect(runSpy.mock.calls.length).toBe(0);
360
+ });
361
+
362
+ it("followUp: false on user-role message: addMessage IS called but runAgent is NOT invoked", async () => {
363
+ const agent = new MockMCPProxyAgent();
364
+ agent.agentId = "ui-msg-agent-nofollowup";
365
+
366
+ const iframe = await setupMCPActivity(
367
+ agent,
368
+ "ui-msg-agent-nofollowup",
369
+ "No followUp test",
370
+ );
371
+
372
+ const runSpy = vi.spyOn(agent, "run");
373
+
374
+ await sendUiMessage(iframe, {
375
+ role: "user",
376
+ content: [{ type: "text", text: "Display only message" }],
377
+ followUp: false,
378
+ });
379
+
380
+ // addMessage should have been called
381
+ const calls = agent.addMessageCalls.filter(
382
+ (c) => c.content === "Display only message",
383
+ );
384
+ expect(calls.length).toBeGreaterThanOrEqual(1);
385
+
386
+ // run() should NOT have been called
387
+ expect(runSpy.mock.calls.length).toBe(0);
388
+ });
389
+
390
+ it("followUp: true on assistant-role message: addMessage IS called AND runAgent IS invoked", async () => {
391
+ const agent = new MockMCPProxyAgent();
392
+ agent.agentId = "ui-msg-agent-force";
393
+
394
+ const iframe = await setupMCPActivity(
395
+ agent,
396
+ "ui-msg-agent-force",
397
+ "Force followUp test",
398
+ );
399
+
400
+ const runSpy = vi.spyOn(agent, "run");
401
+
402
+ await sendUiMessage(iframe, {
403
+ role: "assistant",
404
+ content: [{ type: "text", text: "Assistant with followUp" }],
405
+ followUp: true,
406
+ });
407
+
408
+ // addMessage should have been called
409
+ const calls = agent.addMessageCalls.filter(
410
+ (c) => c.content === "Assistant with followUp",
411
+ );
412
+ expect(calls.length).toBeGreaterThanOrEqual(1);
413
+
414
+ // run() should have been called
415
+ expect(runSpy.mock.calls.length).toBeGreaterThan(0);
416
+ });
417
+
418
+ it("message with text content always adds to agent messages regardless of followUp", async () => {
419
+ const agent = new MockMCPProxyAgent();
420
+ agent.agentId = "ui-msg-agent-all";
421
+
422
+ const iframe = await setupMCPActivity(
423
+ agent,
424
+ "ui-msg-agent-all",
425
+ "All messages test",
426
+ );
427
+
428
+ await sendUiMessage(iframe, {
429
+ role: "user",
430
+ content: [{ type: "text", text: "User msg" }],
431
+ });
432
+
433
+ await sendUiMessage(iframe, {
434
+ role: "assistant",
435
+ content: [{ type: "text", text: "Assistant msg" }],
436
+ });
437
+
438
+ await sendUiMessage(iframe, {
439
+ role: "user",
440
+ content: [{ type: "text", text: "No followUp msg" }],
441
+ followUp: false,
442
+ });
443
+
444
+ const userCalls = agent.addMessageCalls.filter(
445
+ (c) => c.content === "User msg",
446
+ );
447
+ const assistCalls = agent.addMessageCalls.filter(
448
+ (c) => c.content === "Assistant msg",
449
+ );
450
+ const noFollowCalls = agent.addMessageCalls.filter(
451
+ (c) => c.content === "No followUp msg",
452
+ );
453
+
454
+ expect(userCalls.length).toBeGreaterThanOrEqual(1);
455
+ expect(assistCalls.length).toBeGreaterThanOrEqual(1);
456
+ expect(noFollowCalls.length).toBeGreaterThanOrEqual(1);
457
+ });
458
+ });