@copilotkit/react-core 1.56.3 → 1.56.4-canary.1777531098

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,183 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import { beforeEach, vi } from "vitest";
4
+ import { useConfigureSuggestions } from "../../../hooks/use-configure-suggestions";
5
+ import { CopilotChat } from "../CopilotChat";
6
+ import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
7
+ import {
8
+ MockStepwiseAgent,
9
+ runStartedEvent,
10
+ runFinishedEvent,
11
+ textChunkEvent,
12
+ testId,
13
+ } from "../../../__tests__/utils/test-helpers";
14
+ import type { AutoScrollMode } from "../normalize-auto-scroll";
15
+
16
+ // jsdom doesn't implement scrollTo; pin-to-send mode calls it from a rAF
17
+ // callback, so without this stub the cleanup throws an unhandled error.
18
+ beforeEach(() => {
19
+ HTMLElement.prototype.scrollTo = vi.fn();
20
+ });
21
+
22
+ const STATIC_SUGGESTIONS = [
23
+ { title: "Say hello", message: "Hello there!" },
24
+ { title: "Get help", message: "Can you help me?" },
25
+ ];
26
+
27
+ const ChatWithStaticAlwaysSuggestions: React.FC<{
28
+ autoScroll?: AutoScrollMode | boolean;
29
+ consumerAgentId?: string;
30
+ }> = ({ autoScroll, consumerAgentId }) => {
31
+ useConfigureSuggestions({
32
+ suggestions: STATIC_SUGGESTIONS,
33
+ available: "always",
34
+ ...(consumerAgentId ? { consumerAgentId } : {}),
35
+ });
36
+
37
+ return <CopilotChat autoScroll={autoScroll} />;
38
+ };
39
+
40
+ function renderChat({
41
+ agent,
42
+ autoScroll,
43
+ consumerAgentId,
44
+ }: {
45
+ agent: MockStepwiseAgent;
46
+ autoScroll?: AutoScrollMode | boolean;
47
+ consumerAgentId?: string;
48
+ }) {
49
+ return render(
50
+ <CopilotKitProvider agents__unsafe_dev_only={{ default: agent }}>
51
+ <div style={{ height: 400 }}>
52
+ <ChatWithStaticAlwaysSuggestions
53
+ autoScroll={autoScroll}
54
+ consumerAgentId={consumerAgentId}
55
+ />
56
+ </div>
57
+ </CopilotKitProvider>,
58
+ );
59
+ }
60
+
61
+ describe("CopilotChat - static suggestions with available:'always'", () => {
62
+ it("should show suggestions on the welcome screen", async () => {
63
+ const agent = new MockStepwiseAgent();
64
+ renderChat({ agent, consumerAgentId: "default" });
65
+
66
+ await waitFor(() => {
67
+ expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
68
+ });
69
+
70
+ await waitFor(() => {
71
+ expect(screen.getByText("Say hello")).toBeDefined();
72
+ expect(screen.getByText("Get help")).toBeDefined();
73
+ });
74
+ });
75
+
76
+ it("should show suggestions on the welcome screen with global config (no consumerAgentId)", async () => {
77
+ const agent = new MockStepwiseAgent();
78
+ renderChat({ agent });
79
+
80
+ await waitFor(() => {
81
+ expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
82
+ });
83
+
84
+ await waitFor(() => {
85
+ expect(screen.getByText("Say hello")).toBeDefined();
86
+ expect(screen.getByText("Get help")).toBeDefined();
87
+ });
88
+ });
89
+
90
+ it("should hide suggestions during a run and restore them after", async () => {
91
+ const agent = new MockStepwiseAgent();
92
+ renderChat({ agent, consumerAgentId: "default" });
93
+
94
+ await waitFor(() => {
95
+ expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
96
+ });
97
+
98
+ await waitFor(() => {
99
+ expect(screen.getByText("Say hello")).toBeDefined();
100
+ });
101
+
102
+ const input = await screen.findByRole("textbox");
103
+ fireEvent.change(input, { target: { value: "Hi!" } });
104
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
105
+
106
+ await waitFor(() => {
107
+ expect(screen.getByText("Hi!")).toBeDefined();
108
+ });
109
+
110
+ const messageId = testId("msg");
111
+ agent.emit(runStartedEvent());
112
+ agent.emit(textChunkEvent(messageId, "Hello! How can I help?"));
113
+
114
+ // While the run is in flight, suggestions should be hidden — every run
115
+ // changes the conversation context, so we wait for the end-of-run reload
116
+ // before showing them again.
117
+ await waitFor(() => {
118
+ expect(screen.queryByText("Say hello")).toBeNull();
119
+ expect(screen.queryByText("Get help")).toBeNull();
120
+ });
121
+
122
+ agent.emit(runFinishedEvent());
123
+ agent.complete();
124
+
125
+ await waitFor(() => {
126
+ expect(screen.getByText("Hello! How can I help?")).toBeDefined();
127
+ });
128
+
129
+ // After the run, the static "always" config repopulates them.
130
+ await waitFor(
131
+ () => {
132
+ expect(screen.getByText("Say hello")).toBeDefined();
133
+ expect(screen.getByText("Get help")).toBeDefined();
134
+ },
135
+ { timeout: 3000 },
136
+ );
137
+ });
138
+
139
+ it("should hide suggestions during a run in pin-to-send mode", async () => {
140
+ const agent = new MockStepwiseAgent();
141
+ renderChat({ agent, autoScroll: "pin-to-send" });
142
+
143
+ await waitFor(() => {
144
+ expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
145
+ });
146
+
147
+ await waitFor(() => {
148
+ expect(screen.getByText("Say hello")).toBeDefined();
149
+ });
150
+
151
+ const input = await screen.findByRole("textbox");
152
+ fireEvent.change(input, { target: { value: "Hi!" } });
153
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
154
+
155
+ await waitFor(() => {
156
+ expect(screen.getByText("Hi!")).toBeDefined();
157
+ });
158
+
159
+ const messageId = testId("msg");
160
+ agent.emit(runStartedEvent());
161
+ agent.emit(textChunkEvent(messageId, "Hello! How can I help?"));
162
+
163
+ await waitFor(() => {
164
+ expect(screen.queryByText("Say hello")).toBeNull();
165
+ expect(screen.queryByText("Get help")).toBeNull();
166
+ });
167
+
168
+ agent.emit(runFinishedEvent());
169
+ agent.complete();
170
+
171
+ await waitFor(() => {
172
+ expect(screen.getByText("Hello! How can I help?")).toBeDefined();
173
+ });
174
+
175
+ await waitFor(
176
+ () => {
177
+ expect(screen.getByText("Say hello")).toBeDefined();
178
+ expect(screen.getByText("Get help")).toBeDefined();
179
+ },
180
+ { timeout: 3000 },
181
+ );
182
+ });
183
+ });
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
  import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3
3
  import { z } from "zod";
4
+ import { EventType } from "@ag-ui/client";
4
5
  import {
5
6
  MockReconnectableAgent,
6
7
  MockStepwiseAgent,
@@ -10,34 +11,149 @@ import {
10
11
  runStartedEvent,
11
12
  testId,
12
13
  } from "../../../__tests__/utils/test-helpers";
13
- import { ReactActivityMessageRenderer } from "../../../types";
14
+ import type { ReactActivityMessageRenderer } from "../../../types";
14
15
  import {
15
16
  CopilotChatConfigurationProvider,
16
17
  CopilotKitProvider,
17
18
  useCopilotKit,
18
19
  } from "../../../providers";
19
- import { AbstractAgent } from "@ag-ui/client";
20
+ import type { AbstractAgent } from "@ag-ui/client";
20
21
  import { IntelligenceAgent } from "@copilotkit/core";
21
22
  import { getThreadClone } from "../../../hooks/use-agent";
22
23
  import { createA2UIMessageRenderer } from "../../../a2ui/A2UIMessageRenderer";
23
24
  import type { Theme } from "@copilotkit/a2ui-renderer";
24
25
  import { CopilotChat } from "..";
25
26
 
26
- const { mockWebsandboxCreate, mockWebsandboxDestroy } = vi.hoisted(() => {
27
+ const {
28
+ mockWebsandboxCreate,
29
+ mockWebsandboxDestroy,
30
+ mockPhoenixSockets,
31
+ MockPhoenixSocket,
32
+ } = vi.hoisted(() => {
27
33
  const mockDestroy = vi.fn();
28
- const mockCreate = vi.fn(() => ({
34
+ const mockCreate = vi.fn((..._args: unknown[]) => ({
29
35
  iframe: document.createElement("iframe"),
30
36
  promise: Promise.resolve(),
31
37
  run: vi.fn().mockResolvedValue(undefined),
32
38
  destroy: mockDestroy,
33
39
  }));
40
+ const mockSockets: MockPhoenixSocket[] = [];
41
+
42
+ class MockPhoenixPush {
43
+ private callbacks = new Map<string, (response?: unknown) => void>();
44
+
45
+ receive(
46
+ status: string,
47
+ callback: (response?: unknown) => void,
48
+ ): MockPhoenixPush {
49
+ this.callbacks.set(status, callback);
50
+ return this;
51
+ }
52
+
53
+ trigger(status: string, response?: unknown): void {
54
+ this.callbacks.get(status)?.(response);
55
+ }
56
+ }
57
+
58
+ class MockPhoenixChannel {
59
+ public topic: string;
60
+ public params: Record<string, unknown>;
61
+ public left = false;
62
+
63
+ private handlers = new Map<
64
+ string,
65
+ Array<{ ref: number; callback: (payload: unknown) => void }>
66
+ >();
67
+ private joinPush = new MockPhoenixPush();
68
+ private nextRef = 1;
69
+
70
+ constructor(topic: string, params: Record<string, unknown>) {
71
+ this.topic = topic;
72
+ this.params = params;
73
+ }
74
+
75
+ on(event: string, callback: (payload: unknown) => void): number {
76
+ if (!this.handlers.has(event)) {
77
+ this.handlers.set(event, []);
78
+ }
79
+ const ref = this.nextRef;
80
+ this.nextRef += 1;
81
+ this.handlers.get(event)?.push({ ref, callback });
82
+ return ref;
83
+ }
84
+
85
+ off(event: string, ref?: number): void {
86
+ if (ref === undefined) {
87
+ this.handlers.delete(event);
88
+ return;
89
+ }
90
+ this.handlers.set(
91
+ event,
92
+ (this.handlers.get(event) ?? []).filter(
93
+ (handler) => handler.ref !== ref,
94
+ ),
95
+ );
96
+ }
97
+
98
+ join(): MockPhoenixPush {
99
+ return this.joinPush;
100
+ }
101
+
102
+ leave(): void {
103
+ this.left = true;
104
+ }
105
+
106
+ triggerJoin(status: string, response?: unknown): void {
107
+ this.joinPush.trigger(status, response);
108
+ }
109
+
110
+ serverPush(event: string, payload: unknown): void {
111
+ for (const { callback } of this.handlers.get(event) ?? []) {
112
+ callback(payload);
113
+ }
114
+ }
115
+ }
116
+
117
+ class MockPhoenixSocket {
118
+ public channels: MockPhoenixChannel[] = [];
119
+
120
+ constructor(
121
+ public url: string,
122
+ public opts: Record<string, unknown>,
123
+ ) {
124
+ mockSockets.push(this);
125
+ }
126
+
127
+ connect(): void {}
128
+
129
+ disconnect(): void {}
130
+
131
+ onOpen(): void {}
132
+
133
+ onError(): void {}
134
+
135
+ channel(
136
+ topic: string,
137
+ params: Record<string, unknown>,
138
+ ): MockPhoenixChannel {
139
+ const channel = new MockPhoenixChannel(topic, params);
140
+ this.channels.push(channel);
141
+ return channel;
142
+ }
143
+ }
34
144
 
35
145
  return {
36
146
  mockWebsandboxCreate: mockCreate,
37
147
  mockWebsandboxDestroy: mockDestroy,
148
+ mockPhoenixSockets: mockSockets,
149
+ MockPhoenixSocket,
38
150
  };
39
151
  });
40
152
 
153
+ vi.mock("phoenix", () => ({
154
+ Socket: MockPhoenixSocket,
155
+ }));
156
+
41
157
  vi.mock("@jetbrains/websandbox", () => ({
42
158
  default: {
43
159
  create: (...args: unknown[]) => mockWebsandboxCreate(...args),
@@ -329,66 +445,22 @@ describe("CopilotChat activity message rendering", () => {
329
445
  });
330
446
  });
331
447
 
332
- it("restores a completed A2UI surface from an IntelligenceAgent /connect bootstrap plan", async () => {
448
+ it("restores a completed A2UI surface from IntelligenceAgent /connect gateway replay", async () => {
333
449
  const threadId = testId("intelligence-connect-thread");
334
450
  const surfaceId = testId("intelligence-connect-surface");
335
451
  const fetchMock = vi.fn().mockResolvedValueOnce(
336
452
  jsonResponse({
337
- mode: "bootstrap",
338
- latestEventId: "event-3",
339
- events: [
340
- {
341
- type: "RUN_STARTED",
342
- threadId,
343
- run_id: "backend-run-1",
344
- input: {
345
- messages: [
346
- {
347
- id: testId("connect-user-message"),
348
- role: "user",
349
- content: "show me the restored ui",
350
- },
351
- ],
352
- },
353
- },
354
- {
355
- type: "ACTIVITY_SNAPSHOT",
356
- messageId: testId("connect-a2ui-activity"),
357
- activityType: "a2ui-surface",
358
- content: {
359
- a2ui_operations: [
360
- {
361
- version: "v0.9",
362
- createSurface: {
363
- surfaceId,
364
- catalogId:
365
- "https://a2ui.org/specification/v0_9/basic_catalog.json",
366
- },
367
- },
368
- {
369
- version: "v0.9",
370
- updateComponents: {
371
- surfaceId,
372
- components: [
373
- {
374
- id: "root",
375
- component: "Text",
376
- text: "Restored dashboard",
377
- variant: "body",
378
- },
379
- ],
380
- },
381
- },
382
- ],
383
- },
384
- },
385
- {
386
- type: "RUN_FINISHED",
387
- },
388
- ],
453
+ threadId,
454
+ runId: null,
455
+ joinToken: "join-token-1",
456
+ realtime: {
457
+ clientUrl: "ws://localhost:4000/client",
458
+ topic: `thread:${threadId}`,
459
+ },
389
460
  }),
390
461
  );
391
462
  vi.stubGlobal("fetch", fetchMock);
463
+ mockPhoenixSockets.length = 0;
392
464
 
393
465
  const agent = new IntelligenceAgent({
394
466
  url: "ws://localhost:4000/client",
@@ -409,6 +481,70 @@ describe("CopilotChat activity message rendering", () => {
409
481
  await waitFor(() => {
410
482
  expect(fetchMock).toHaveBeenCalledTimes(1);
411
483
  });
484
+
485
+ await waitFor(() => {
486
+ expect(mockPhoenixSockets).toHaveLength(1);
487
+ expect(mockPhoenixSockets[0]?.channels).toHaveLength(1);
488
+ });
489
+
490
+ const channel = mockPhoenixSockets[0]!.channels[0]!;
491
+ expect(channel.topic).toBe(`thread:${threadId}`);
492
+ expect(channel.params).toEqual({
493
+ stream_mode: "connect",
494
+ last_seen_event_id: null,
495
+ });
496
+
497
+ channel.triggerJoin("ok");
498
+ channel.serverPush("ag_ui_event", {
499
+ type: EventType.RUN_STARTED,
500
+ threadId,
501
+ run_id: "backend-run-1",
502
+ input: {
503
+ messages: [
504
+ {
505
+ id: testId("connect-user-message"),
506
+ role: "user",
507
+ content: "show me the restored ui",
508
+ },
509
+ ],
510
+ },
511
+ });
512
+ channel.serverPush("ag_ui_event", {
513
+ type: EventType.ACTIVITY_SNAPSHOT,
514
+ messageId: testId("connect-a2ui-activity"),
515
+ activityType: "a2ui-surface",
516
+ content: {
517
+ a2ui_operations: [
518
+ {
519
+ version: "v0.9",
520
+ createSurface: {
521
+ surfaceId,
522
+ catalogId:
523
+ "https://a2ui.org/specification/v0_9/basic_catalog.json",
524
+ },
525
+ },
526
+ {
527
+ version: "v0.9",
528
+ updateComponents: {
529
+ surfaceId,
530
+ components: [
531
+ {
532
+ id: "root",
533
+ component: "Text",
534
+ text: "Restored dashboard",
535
+ variant: "body",
536
+ },
537
+ ],
538
+ },
539
+ },
540
+ ],
541
+ },
542
+ });
543
+ channel.serverPush("ag_ui_event", {
544
+ type: EventType.RUN_FINISHED,
545
+ });
546
+ channel.serverPush("stream_idle", { latestEventId: "event-3" });
547
+
412
548
  await waitFor(() => {
413
549
  expect(screen.getByText("show me the restored ui")).toBeDefined();
414
550
  });