@copilotkit/react-core 1.55.0-next.9 → 1.55.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +36 -6
  2. package/dist/{copilotkit-DeOzjPsb.mjs → copilotkit-BY5S1-0P.mjs} +2402 -552
  3. package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
  4. package/dist/{copilotkit-BqcyhQjT.d.mts → copilotkit-BuhSUZHb.d.mts} +228 -17
  5. package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
  6. package/dist/{copilotkit-BDNjFNmk.cjs → copilotkit-Bz5-ImDl.cjs} +2421 -541
  7. package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
  8. package/dist/{copilotkit-l-IBF4Xp.d.cts → copilotkit-dwDWYpya.d.cts} +228 -17
  9. package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.cts +1 -1
  12. package/dist/index.d.mts +1 -1
  13. package/dist/index.mjs +1 -1
  14. package/dist/index.umd.js +1400 -238
  15. package/dist/index.umd.js.map +1 -1
  16. package/dist/v2/index.cjs +13 -1
  17. package/dist/v2/index.css +1 -1
  18. package/dist/v2/index.d.cts +3 -3
  19. package/dist/v2/index.d.mts +3 -3
  20. package/dist/v2/index.mjs +3 -2
  21. package/dist/v2/index.umd.js +2442 -552
  22. package/dist/v2/index.umd.js.map +1 -1
  23. package/package.json +62 -54
  24. package/scripts/scope-preflight.mjs +1 -2
  25. package/src/components/CopilotListeners.tsx +41 -8
  26. package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
  27. package/src/components/toast/toast-provider.tsx +269 -194
  28. package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
  29. package/src/v2/__tests__/utils/test-helpers.tsx +67 -0
  30. package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
  31. package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
  32. package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
  33. package/src/v2/components/CopilotKitInspector.tsx +2 -0
  34. package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
  35. package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
  36. package/src/v2/components/chat/CopilotChat.tsx +193 -50
  37. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
  38. package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
  39. package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
  40. package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
  41. package/src/v2/components/chat/CopilotChatMessageView.tsx +253 -149
  42. package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
  43. package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
  44. package/src/v2/components/chat/CopilotChatView.tsx +179 -66
  45. package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
  46. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
  47. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
  48. package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
  49. package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
  50. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +43 -2
  51. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
  52. package/src/v2/components/chat/index.ts +9 -0
  53. package/src/v2/components/chat/scroll-element-context.ts +13 -0
  54. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
  55. package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
  56. package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
  57. package/src/v2/hooks/index.ts +5 -0
  58. package/src/v2/hooks/use-agent.tsx +95 -10
  59. package/src/v2/hooks/use-attachments.tsx +269 -0
  60. package/src/v2/hooks/use-frontend-tool.tsx +5 -2
  61. package/src/v2/hooks/use-render-activity-message.tsx +9 -2
  62. package/src/v2/hooks/use-threads.tsx +35 -15
  63. package/src/v2/index.ts +5 -1
  64. package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
  65. package/src/v2/lib/__tests__/slots.test.ts +56 -0
  66. package/src/v2/lib/processPartialHtml.ts +45 -0
  67. package/src/v2/lib/slots.tsx +42 -1
  68. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
  69. package/src/v2/providers/CopilotKitProvider.tsx +268 -32
  70. package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
  71. package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
  72. package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
  73. package/src/v2/providers/index.ts +7 -0
  74. package/src/v2/styles/globals.css +2 -1
  75. package/src/v2/types/index.ts +1 -0
  76. package/src/v2/types/sandbox-function.ts +11 -0
  77. package/dist/copilotkit-BDNjFNmk.cjs.map +0 -1
  78. package/dist/copilotkit-BqcyhQjT.d.mts.map +0 -1
  79. package/dist/copilotkit-DeOzjPsb.mjs.map +0 -1
  80. package/dist/copilotkit-l-IBF4Xp.d.cts.map +0 -1
  81. package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
@@ -1,4 +1,11 @@
1
- import React, { useRef, useState, useEffect } from "react";
1
+ import React, {
2
+ useCallback,
3
+ useRef,
4
+ useState,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ } from "react";
8
+ import { ScrollElementContext } from "./scroll-element-context";
2
9
  import { WithSlots, SlotValue, renderSlot } from "../../lib/slots";
3
10
  import CopilotChatMessageView from "./CopilotChatMessageView";
4
11
  import CopilotChatInput, {
@@ -10,13 +17,15 @@ import CopilotChatSuggestionView, {
10
17
  } from "./CopilotChatSuggestionView";
11
18
  import { Suggestion } from "@copilotkit/core";
12
19
  import { Message } from "@ag-ui/core";
20
+ import type { Attachment } from "@copilotkit/shared";
21
+ import { CopilotChatAttachmentQueue } from "./CopilotChatAttachmentQueue";
13
22
  import { twMerge } from "tailwind-merge";
14
23
  import {
15
24
  StickToBottom,
16
25
  useStickToBottom,
17
26
  useStickToBottomContext,
18
27
  } from "use-stick-to-bottom";
19
- import { ChevronDown } from "lucide-react";
28
+ import { ChevronDown, Upload } from "lucide-react";
20
29
  import { Button } from "../../components/ui/button";
21
30
  import { cn } from "../../lib/utils";
22
31
  import {
@@ -64,6 +73,14 @@ export type CopilotChatViewProps = WithSlots<
64
73
  onCancelTranscribe?: () => void;
65
74
  onFinishTranscribe?: () => void;
66
75
  onFinishTranscribeWithAudio?: (audioBlob: Blob) => Promise<void>;
76
+ // Attachment props
77
+ attachments?: Attachment[];
78
+ onRemoveAttachment?: (id: string) => void;
79
+ onAddFile?: () => void;
80
+ dragOver?: boolean;
81
+ onDragOver?: (e: React.DragEvent) => void;
82
+ onDragLeave?: (e: React.DragEvent) => void;
83
+ onDrop?: (e: React.DragEvent) => void;
67
84
  /**
68
85
  * @deprecated Use the `input` slot's `disclaimer` prop instead:
69
86
  * ```tsx
@@ -74,6 +91,24 @@ export type CopilotChatViewProps = WithSlots<
74
91
  } & React.HTMLAttributes<HTMLDivElement>
75
92
  >;
76
93
 
94
+ function DropOverlay() {
95
+ return (
96
+ <div
97
+ className={cn(
98
+ "cpk:absolute cpk:inset-0 cpk:z-50 cpk:pointer-events-none",
99
+ "cpk:flex cpk:items-center cpk:justify-center",
100
+ "cpk:bg-primary/5 cpk:backdrop-blur-[2px]",
101
+ "cpk:border-2 cpk:border-dashed cpk:border-primary/40 cpk:rounded-lg cpk:m-2",
102
+ )}
103
+ >
104
+ <div className="cpk:flex cpk:flex-col cpk:items-center cpk:gap-2 cpk:text-primary/70">
105
+ <Upload className="cpk:w-8 cpk:h-8" />
106
+ <span className="cpk:text-sm cpk:font-medium">Drop files here</span>
107
+ </div>
108
+ </div>
109
+ );
110
+ }
111
+
77
112
  export function CopilotChatView({
78
113
  messageView,
79
114
  input,
@@ -96,6 +131,14 @@ export function CopilotChatView({
96
131
  onCancelTranscribe,
97
132
  onFinishTranscribe,
98
133
  onFinishTranscribeWithAudio,
134
+ // Attachment props
135
+ attachments,
136
+ onRemoveAttachment,
137
+ onAddFile,
138
+ dragOver,
139
+ onDragOver,
140
+ onDragLeave,
141
+ onDrop,
99
142
  // Deprecated — forwarded to input slot
100
143
  disclaimer,
101
144
  children,
@@ -171,6 +214,7 @@ export function CopilotChatView({
171
214
  onCancelTranscribe,
172
215
  onFinishTranscribe,
173
216
  onFinishTranscribeWithAudio,
217
+ onAddFile,
174
218
  positioning: "static",
175
219
  keyboardHeight: isKeyboardOpen ? keyboardHeight : 0,
176
220
  containerRef: inputContainerRef,
@@ -229,6 +273,7 @@ export function CopilotChatView({
229
273
  onCancelTranscribe,
230
274
  onFinishTranscribe,
231
275
  onFinishTranscribeWithAudio,
276
+ onAddFile,
232
277
  positioning: "static",
233
278
  showDisclaimer: true,
234
279
  ...(disclaimer !== undefined ? { disclaimer } : {}),
@@ -238,11 +283,25 @@ export function CopilotChatView({
238
283
  const welcomeScreenSlot = (
239
284
  welcomeScreen === true ? undefined : welcomeScreen
240
285
  ) as SlotValue<React.FC<WelcomeScreenProps>> | undefined;
286
+ // Wrap the input with attachment queue above it
287
+ const inputWithAttachments = (
288
+ <div className="cpk:w-full">
289
+ {attachments && attachments.length > 0 && (
290
+ <CopilotChatAttachmentQueue
291
+ attachments={attachments}
292
+ onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
293
+ className="cpk:mb-2"
294
+ />
295
+ )}
296
+ {BoundInputForWelcome}
297
+ </div>
298
+ );
299
+
241
300
  const BoundWelcomeScreen = renderSlot(
242
301
  welcomeScreenSlot,
243
302
  CopilotChatView.WelcomeScreen,
244
303
  {
245
- input: BoundInputForWelcome,
304
+ input: inputWithAttachments,
246
305
  suggestionView: BoundSuggestionView ?? <></>,
247
306
  },
248
307
  );
@@ -252,12 +311,16 @@ export function CopilotChatView({
252
311
  data-copilotkit
253
312
  data-testid="copilot-chat"
254
313
  data-copilot-running={isRunning ? "true" : "false"}
255
- className={twMerge(
314
+ onDragOver={onDragOver}
315
+ onDragLeave={onDragLeave}
316
+ onDrop={onDrop}
317
+ className={cn(
256
318
  "copilotKitChat cpk:relative cpk:h-full cpk:flex cpk:flex-col",
257
319
  className,
258
320
  )}
259
321
  {...props}
260
322
  >
323
+ {dragOver && <DropOverlay />}
261
324
  {BoundWelcomeScreen}
262
325
  </div>
263
326
  );
@@ -281,14 +344,28 @@ export function CopilotChatView({
281
344
  data-copilotkit
282
345
  data-testid="copilot-chat"
283
346
  data-copilot-running={isRunning ? "true" : "false"}
284
- className={twMerge(
347
+ onDragOver={onDragOver}
348
+ onDragLeave={onDragLeave}
349
+ onDrop={onDrop}
350
+ className={cn(
285
351
  "copilotKitChat cpk:relative cpk:h-full cpk:flex cpk:flex-col",
286
352
  className,
287
353
  )}
288
354
  {...props}
289
355
  >
356
+ {dragOver && <DropOverlay />}
290
357
  {BoundScrollView}
291
358
 
359
+ <div className="cpk:max-w-3xl cpk:mx-auto cpk:w-full">
360
+ {attachments && attachments.length > 0 && (
361
+ <CopilotChatAttachmentQueue
362
+ attachments={attachments}
363
+ onRemoveAttachment={(id) => onRemoveAttachment?.(id)}
364
+ className="cpk:px-4"
365
+ />
366
+ )}
367
+ </div>
368
+
292
369
  {BoundInput}
293
370
  </div>
294
371
  );
@@ -311,42 +388,57 @@ export namespace CopilotChatView {
311
388
  inputContainerHeight,
312
389
  isResizing,
313
390
  }) => {
314
- const { isAtBottom, scrollToBottom } = useStickToBottomContext();
391
+ const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
392
+
393
+ // Capture the scroll element in state so the context value is reactive —
394
+ // consumers re-render when the element is first set rather than reading a
395
+ // ref that silently stays null until after their own layout effects fire.
396
+ const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
397
+ useLayoutEffect(() => {
398
+ setScrollEl(scrollRef.current ?? null);
399
+ // scrollRef is a stable object; omitting from deps is intentional.
400
+ // eslint-disable-next-line react-hooks/exhaustive-deps
401
+ }, []);
315
402
 
316
403
  const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
317
404
 
318
405
  return (
319
- <>
320
- <StickToBottom.Content
321
- className="cpk:overflow-y-auto cpk:overflow-x-hidden"
322
- style={{ flex: "1 1 0%", minHeight: 0 }}
323
- >
324
- <div className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6">
325
- {children}
326
- </div>
327
- </StickToBottom.Content>
406
+ // Provide the scroll element so CopilotChatMessageView can feed it to
407
+ // useVirtualizer's getScrollElement. Using state (not the raw ref) means
408
+ // the context value updates reactively when the element mounts.
409
+ <ScrollElementContext.Provider value={scrollEl}>
410
+ <>
411
+ <StickToBottom.Content
412
+ className="cpk:overflow-y-auto cpk:overflow-x-hidden"
413
+ style={{ flex: "1 1 0%", minHeight: 0 }}
414
+ >
415
+ <div className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6">
416
+ {children}
417
+ </div>
418
+ </StickToBottom.Content>
328
419
 
329
- {/* Feather gradient overlay */}
330
- {BoundFeather}
420
+ {/* Feather gradient overlay */}
421
+ {BoundFeather}
331
422
 
332
- {/* Scroll to bottom button - hidden during resize */}
333
- {!isAtBottom && !isResizing && (
334
- <div
335
- className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
336
- style={{
337
- bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
338
- }}
339
- >
340
- {renderSlot(
341
- scrollToBottomButton,
342
- CopilotChatView.ScrollToBottomButton,
343
- {
344
- onClick: () => scrollToBottom(),
345
- },
346
- )}
347
- </div>
348
- )}
349
- </>
423
+ {/* Scroll to bottom button - hidden during resize */}
424
+ {!isAtBottom && !isResizing && (
425
+ <div
426
+ className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
427
+ style={{
428
+ bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
429
+ }}
430
+ >
431
+ {renderSlot(
432
+ scrollToBottomButton,
433
+ CopilotChatView.ScrollToBottomButton,
434
+ {
435
+ onClick: () => scrollToBottom(),
436
+ },
437
+ )}
438
+ </div>
439
+ )}
440
+ </>
441
+ </ScrollElementContext.Provider>
350
442
  );
351
443
  };
352
444
 
@@ -373,6 +465,23 @@ export namespace CopilotChatView {
373
465
  const [hasMounted, setHasMounted] = useState(false);
374
466
  const { scrollRef, contentRef, scrollToBottom } = useStickToBottom();
375
467
  const [showScrollButton, setShowScrollButton] = useState(false);
468
+ // Tracks the scroll container element for the non-autoScroll path so the
469
+ // context value is reactive (element state, not a ref).
470
+ const [nonAutoScrollEl, setNonAutoScrollEl] = useState<HTMLElement | null>(
471
+ null,
472
+ );
473
+
474
+ // Callback ref that keeps scrollRef in sync with the DOM element while also
475
+ // updating context state — eliminates the need for a useLayoutEffect.
476
+ const nonAutoScrollRefCallback = useCallback(
477
+ (el: HTMLElement | null) => {
478
+ scrollRef.current = el;
479
+ setNonAutoScrollEl(el);
480
+ },
481
+ // scrollRef is a stable object from useStickToBottom; safe to omit.
482
+ // eslint-disable-next-line react-hooks/exhaustive-deps
483
+ [],
484
+ );
376
485
 
377
486
  useEffect(() => {
378
487
  setHasMounted(true);
@@ -422,42 +531,46 @@ export namespace CopilotChatView {
422
531
  const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {});
423
532
 
424
533
  return (
425
- <div
426
- ref={scrollRef}
427
- className={cn(
428
- "cpk:h-full cpk:max-h-full cpk:flex cpk:flex-col cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden cpk:relative",
429
- className,
430
- )}
431
- {...props}
432
- >
534
+ // Provide the scroll element so CopilotChatMessageView can use it for
535
+ // useVirtualizer. Element state (not a ref) keeps the context reactive.
536
+ <ScrollElementContext.Provider value={nonAutoScrollEl}>
433
537
  <div
434
- ref={contentRef}
435
- className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6"
538
+ ref={nonAutoScrollRefCallback}
539
+ className={cn(
540
+ "cpk:h-full cpk:max-h-full cpk:flex cpk:flex-col cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden cpk:relative",
541
+ className,
542
+ )}
543
+ {...props}
436
544
  >
437
- {children}
438
- </div>
439
-
440
- {/* Feather gradient overlay */}
441
- {BoundFeather}
442
-
443
- {/* Scroll to bottom button for manual mode */}
444
- {showScrollButton && !isResizing && (
445
545
  <div
446
- className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
447
- style={{
448
- bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
449
- }}
546
+ ref={contentRef}
547
+ className="cpk:px-4 cpk:sm:px-0 cpk:[div[data-sidebar-chat]_&]:px-8 cpk:[div[data-popup-chat]_&]:px-6"
450
548
  >
451
- {renderSlot(
452
- scrollToBottomButton,
453
- CopilotChatView.ScrollToBottomButton,
454
- {
455
- onClick: () => scrollToBottom(),
456
- },
457
- )}
549
+ {children}
458
550
  </div>
459
- )}
460
- </div>
551
+
552
+ {/* Feather gradient overlay */}
553
+ {BoundFeather}
554
+
555
+ {/* Scroll to bottom button for manual mode */}
556
+ {showScrollButton && !isResizing && (
557
+ <div
558
+ className="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30 cpk:pointer-events-none"
559
+ style={{
560
+ bottom: `${inputContainerHeight + FEATHER_HEIGHT + 16}px`,
561
+ }}
562
+ >
563
+ {renderSlot(
564
+ scrollToBottomButton,
565
+ CopilotChatView.ScrollToBottomButton,
566
+ {
567
+ onClick: () => scrollToBottom(),
568
+ },
569
+ )}
570
+ </div>
571
+ )}
572
+ </div>
573
+ </ScrollElementContext.Provider>
461
574
  );
462
575
  }
463
576
 
@@ -0,0 +1,168 @@
1
+ import React from "react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { screen, fireEvent, waitFor } from "@testing-library/react";
4
+ import {
5
+ MockStepwiseAgent,
6
+ renderWithCopilotKit,
7
+ } from "../../../__tests__/utils/test-helpers";
8
+ import { CopilotChat } from "../CopilotChat";
9
+ import type { AttachmentUploadError } from "@copilotkit/shared";
10
+ import { type BaseEvent, type RunAgentInput } from "@ag-ui/client";
11
+ import { Observable, EMPTY } from "rxjs";
12
+
13
+ class NoopAgent extends MockStepwiseAgent {
14
+ run(_input: RunAgentInput): Observable<BaseEvent> {
15
+ return EMPTY;
16
+ }
17
+ connect(_input: RunAgentInput): Observable<BaseEvent> {
18
+ return EMPTY;
19
+ }
20
+ }
21
+
22
+ function createFile(name: string, size: number, type: string): File {
23
+ const content = new Uint8Array(size);
24
+ return new File([content], name, { type });
25
+ }
26
+
27
+ function renderChat(props: {
28
+ onUploadFailed?: (error: AttachmentUploadError) => void;
29
+ accept?: string;
30
+ maxSize?: number;
31
+ onUpload?: (file: File) => any;
32
+ }) {
33
+ const agent = new NoopAgent();
34
+ return renderWithCopilotKit({
35
+ agent,
36
+ children: (
37
+ <CopilotChat
38
+ welcomeScreen={false}
39
+ attachments={{
40
+ enabled: true,
41
+ accept: props.accept,
42
+ maxSize: props.maxSize,
43
+ onUploadFailed: props.onUploadFailed,
44
+ onUpload: props.onUpload,
45
+ }}
46
+ />
47
+ ),
48
+ });
49
+ }
50
+
51
+ async function dropFiles(container: HTMLElement, files: File[]) {
52
+ const dropTarget = container.querySelector('[data-testid="copilot-chat"]');
53
+ if (!dropTarget) {
54
+ throw new Error("Could not find copilot-chat drop target");
55
+ }
56
+
57
+ fireEvent.dragOver(dropTarget, {
58
+ dataTransfer: { files, types: ["Files"] },
59
+ });
60
+ fireEvent.drop(dropTarget, {
61
+ dataTransfer: { files, types: ["Files"] },
62
+ });
63
+ }
64
+
65
+ describe("CopilotChat attachments", () => {
66
+ describe("onUploadFailed", () => {
67
+ it("fires with 'invalid-type' when file does not match accept filter", async () => {
68
+ const onUploadFailed = vi.fn();
69
+ const { container } = renderChat({
70
+ accept: "image/*",
71
+ onUploadFailed,
72
+ });
73
+
74
+ const pdfFile = createFile("document.pdf", 1024, "application/pdf");
75
+ await dropFiles(container, [pdfFile]);
76
+
77
+ await waitFor(() => {
78
+ expect(onUploadFailed).toHaveBeenCalledTimes(1);
79
+ });
80
+
81
+ const error: AttachmentUploadError = onUploadFailed.mock.calls[0][0];
82
+ expect(error.reason).toBe("invalid-type");
83
+ expect(error.file).toBe(pdfFile);
84
+ expect(error.message).toContain("document.pdf");
85
+ });
86
+
87
+ it("fires with 'file-too-large' when file exceeds maxSize", async () => {
88
+ const onUploadFailed = vi.fn();
89
+ const { container } = renderChat({
90
+ maxSize: 100,
91
+ onUploadFailed,
92
+ });
93
+
94
+ const largeFile = createFile("big.png", 200, "image/png");
95
+ await dropFiles(container, [largeFile]);
96
+
97
+ await waitFor(() => {
98
+ expect(onUploadFailed).toHaveBeenCalledTimes(1);
99
+ });
100
+
101
+ const error: AttachmentUploadError = onUploadFailed.mock.calls[0][0];
102
+ expect(error.reason).toBe("file-too-large");
103
+ expect(error.file).toBe(largeFile);
104
+ expect(error.message).toContain("big.png");
105
+ });
106
+
107
+ it("fires with 'upload-failed' when onUpload throws", async () => {
108
+ const onUploadFailed = vi.fn();
109
+ const { container } = renderChat({
110
+ onUpload: () => {
111
+ throw new Error("S3 upload failed");
112
+ },
113
+ onUploadFailed,
114
+ });
115
+
116
+ const file = createFile("photo.png", 50, "image/png");
117
+ await dropFiles(container, [file]);
118
+
119
+ await waitFor(() => {
120
+ expect(onUploadFailed).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ const error: AttachmentUploadError = onUploadFailed.mock.calls[0][0];
124
+ expect(error.reason).toBe("upload-failed");
125
+ expect(error.file).toBe(file);
126
+ expect(error.message).toBe("S3 upload failed");
127
+ });
128
+
129
+ it("fires multiple times for multiple rejected files", async () => {
130
+ const onUploadFailed = vi.fn();
131
+ const { container } = renderChat({
132
+ accept: "image/*",
133
+ onUploadFailed,
134
+ });
135
+
136
+ const pdf = createFile("a.pdf", 100, "application/pdf");
137
+ const doc = createFile(
138
+ "b.docx",
139
+ 100,
140
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
141
+ );
142
+ await dropFiles(container, [pdf, doc]);
143
+
144
+ await waitFor(() => {
145
+ expect(onUploadFailed).toHaveBeenCalledTimes(2);
146
+ });
147
+
148
+ expect(onUploadFailed.mock.calls[0][0].reason).toBe("invalid-type");
149
+ expect(onUploadFailed.mock.calls[1][0].reason).toBe("invalid-type");
150
+ });
151
+
152
+ it("does not fire for valid files", async () => {
153
+ const onUploadFailed = vi.fn();
154
+ const { container } = renderChat({
155
+ accept: "image/*",
156
+ maxSize: 10000,
157
+ onUploadFailed,
158
+ });
159
+
160
+ const validFile = createFile("photo.png", 500, "image/png");
161
+ await dropFiles(container, [validFile]);
162
+
163
+ // Give time for any async processing
164
+ await new Promise((r) => setTimeout(r, 100));
165
+ expect(onUploadFailed).not.toHaveBeenCalled();
166
+ });
167
+ });
168
+ });
@@ -11,6 +11,8 @@ import {
11
11
  } from "../../../__tests__/utils/test-helpers";
12
12
  import { ReactActivityMessageRenderer } from "../../../types";
13
13
  import { useCopilotKit } from "../../../providers";
14
+ import { AbstractAgent } from "@ag-ui/client";
15
+ import { getThreadClone } from "../../../hooks/use-agent";
14
16
 
15
17
  describe("CopilotChat activity message rendering", () => {
16
18
  it("renders custom components for activity snapshots", async () => {
@@ -24,9 +26,9 @@ describe("CopilotChat activity message rendering", () => {
24
26
  }> = {
25
27
  activityType: "search-progress",
26
28
  content: z.object({ status: z.string(), percent: z.number() }),
27
- render: ({ content, agent }) => (
29
+ render: ({ content, agent: rendererAgent }) => (
28
30
  <div data-testid="activity-card">
29
- {content.status} · {content.percent}% · {agent?.agentId}
31
+ {content.status} · {content.percent}% · {rendererAgent?.agentId}
30
32
  </div>
31
33
  ),
32
34
  };
@@ -147,4 +149,63 @@ describe("CopilotChat activity message rendering", () => {
147
149
  expect(capturedCopilotkit).not.toBeNull();
148
150
  expect(capturedCopilotkit).toBeDefined();
149
151
  });
152
+
153
+ it("passes the per-thread clone (not the registry agent) to activity message renderers", async () => {
154
+ // Regression test for: A2UI button clicks firing runAgent on the registry
155
+ // agent instead of the per-thread clone that CopilotChat renders from.
156
+ // Caused by useRenderActivityMessage calling copilotkit.getAgent() directly
157
+ // instead of getThreadClone(registryAgent, threadId) ?? registryAgent.
158
+ const agent = new MockStepwiseAgent();
159
+ const agentId = "action-agent";
160
+ agent.agentId = agentId;
161
+ const threadId = "thread-for-action-test";
162
+
163
+ let capturedAgent: AbstractAgent | undefined;
164
+
165
+ const activityRenderer: ReactActivityMessageRenderer<{ label: string }> = {
166
+ activityType: "button-action",
167
+ content: z.object({ label: z.string() }),
168
+ render: ({ content, agent: renderedAgent }) => {
169
+ capturedAgent = renderedAgent;
170
+ return <button data-testid="action-button">{content.label}</button>;
171
+ },
172
+ };
173
+
174
+ renderWithCopilotKit({
175
+ agents: { [agentId]: agent },
176
+ agentId,
177
+ threadId,
178
+ renderActivityMessages: [activityRenderer],
179
+ });
180
+
181
+ const input = await screen.findByRole("textbox");
182
+ fireEvent.change(input, { target: { value: "show me buttons" } });
183
+ fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
184
+
185
+ await waitFor(() => {
186
+ expect(screen.getByText("show me buttons")).toBeDefined();
187
+ });
188
+
189
+ agent.emit(runStartedEvent());
190
+ agent.emit(
191
+ activitySnapshotEvent({
192
+ messageId: testId("activity-action"),
193
+ activityType: "button-action",
194
+ content: { label: "Click Me" },
195
+ }),
196
+ );
197
+ agent.emit(runFinishedEvent());
198
+
199
+ await waitFor(() => {
200
+ expect(screen.getByTestId("action-button")).toBeDefined();
201
+ });
202
+
203
+ // CopilotChat creates a per-thread clone via useAgent. The activity renderer
204
+ // must receive that clone so that handleAction → runAgent targets the same
205
+ // instance chat is rendering from.
206
+ const clone = getThreadClone(agent, threadId);
207
+ expect(clone).toBeDefined();
208
+ expect(capturedAgent).toBe(clone);
209
+ expect(capturedAgent).not.toBe(agent); // must NOT be the registry agent
210
+ });
150
211
  });