@copilotkit/react-core 1.56.0 → 1.56.2-canary.pin-to-send

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 (60) hide show
  1. package/dist/{copilotkit-BebqQrYT.mjs → copilotkit-BBYbekCa.mjs} +265 -76
  2. package/dist/copilotkit-BBYbekCa.mjs.map +1 -0
  3. package/dist/{copilotkit-Cvb6WpAX.cjs → copilotkit-D5JT2Pu3.cjs} +264 -75
  4. package/dist/copilotkit-D5JT2Pu3.cjs.map +1 -0
  5. package/dist/{copilotkit-f2Uq0RwG.d.mts → copilotkit-DArT2Iuw.d.mts} +71 -18
  6. package/dist/copilotkit-DArT2Iuw.d.mts.map +1 -0
  7. package/dist/{copilotkit-Dv8zU8_U.d.cts → copilotkit-KEc28l8G.d.cts} +71 -18
  8. package/dist/copilotkit-KEc28l8G.d.cts.map +1 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.cts +1 -1
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.umd.js +30 -46
  14. package/dist/index.umd.js.map +1 -1
  15. package/dist/v2/index.cjs +1 -1
  16. package/dist/v2/index.css +1 -1
  17. package/dist/v2/index.d.cts +2 -2
  18. package/dist/v2/index.d.mts +2 -2
  19. package/dist/v2/index.mjs +1 -1
  20. package/dist/v2/index.umd.js +264 -79
  21. package/dist/v2/index.umd.js.map +1 -1
  22. package/package.json +6 -6
  23. package/src/components/CopilotListeners.tsx +15 -4
  24. package/src/components/__tests__/CopilotListeners.test.tsx +38 -0
  25. package/src/v2/components/chat/CopilotChat.tsx +80 -4
  26. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +4 -4
  27. package/src/v2/components/chat/CopilotChatInput.tsx +43 -2
  28. package/src/v2/components/chat/CopilotChatView.tsx +206 -11
  29. package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
  30. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +300 -2
  31. package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.thumbs.test.tsx +72 -0
  32. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +38 -0
  33. package/src/v2/components/chat/__tests__/CopilotChatView.connectingGate.test.tsx +56 -0
  34. package/src/v2/components/chat/__tests__/CopilotChatView.pinToSend.test.tsx +94 -0
  35. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +0 -1
  36. package/src/v2/components/chat/__tests__/normalize-auto-scroll.test.ts +37 -0
  37. package/src/v2/components/chat/index.ts +2 -0
  38. package/src/v2/components/chat/last-user-message-context.ts +21 -0
  39. package/src/v2/components/chat/normalize-auto-scroll.ts +17 -0
  40. package/src/v2/components/license-warning-banner.tsx +20 -1
  41. package/src/v2/components/ui/button.tsx +12 -11
  42. package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +6 -0
  43. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +6 -0
  44. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +76 -50
  45. package/src/v2/hooks/__tests__/use-pin-to-send.test.tsx +219 -0
  46. package/src/v2/hooks/__tests__/use-render-custom-messages.test.tsx +55 -0
  47. package/src/v2/hooks/__tests__/use-threads.test.tsx +68 -0
  48. package/src/v2/hooks/use-agent.tsx +34 -77
  49. package/src/v2/hooks/use-pin-to-send.ts +94 -0
  50. package/src/v2/hooks/use-render-custom-messages.tsx +1 -1
  51. package/src/v2/hooks/use-render-tool-call.tsx +3 -0
  52. package/src/v2/hooks/use-render-tool.tsx +3 -0
  53. package/src/v2/hooks/use-threads.tsx +55 -12
  54. package/src/v2/providers/CopilotKitProvider.tsx +2 -11
  55. package/src/v2/types/defineToolCallRenderer.ts +3 -0
  56. package/src/v2/types/react-tool-call-renderer.ts +3 -0
  57. package/dist/copilotkit-BebqQrYT.mjs.map +0 -1
  58. package/dist/copilotkit-Cvb6WpAX.cjs.map +0 -1
  59. package/dist/copilotkit-Dv8zU8_U.d.cts.map +0 -1
  60. package/dist/copilotkit-f2Uq0RwG.d.mts.map +0 -1
@@ -1,4 +1,9 @@
1
- import React from "react";
1
+ import React, { useEffect } from "react";
2
+
3
+ // Total reserved vertical space for the fixed license banner: banner height
4
+ // (~36px) + bottom offset (8px) + visual gap above the chat input (~8px).
5
+ const LICENSE_BANNER_OFFSET_PX = 52;
6
+ const LICENSE_BANNER_OFFSET_VAR = "--copilotkit-license-banner-offset";
2
7
 
3
8
  interface LicenseWarningBannerProps {
4
9
  type:
@@ -72,6 +77,20 @@ function BannerShell({
72
77
  actionUrl: string;
73
78
  onDismiss?: () => void;
74
79
  }) {
80
+ // Publish the banner's reserved bottom offset so the chat input can lift
81
+ // itself above it via padding-bottom: var(--copilotkit-license-banner-offset).
82
+ useEffect(() => {
83
+ if (typeof document === "undefined") return;
84
+ const root = document.documentElement;
85
+ root.style.setProperty(
86
+ LICENSE_BANNER_OFFSET_VAR,
87
+ `${LICENSE_BANNER_OFFSET_PX}px`,
88
+ );
89
+ return () => {
90
+ root.style.removeProperty(LICENSE_BANNER_OFFSET_VAR);
91
+ };
92
+ }, []);
93
+
75
94
  return (
76
95
  <div style={{ ...BANNER_STYLES.base, ...getSeverityStyle(severity) }}>
77
96
  <span>{message}</span>
@@ -99,25 +99,26 @@ const buttonVariants = cva(
99
99
  },
100
100
  );
101
101
 
102
- function Button({
103
- className,
104
- variant,
105
- size,
106
- asChild = false,
107
- ...props
108
- }: React.ComponentProps<"button"> &
109
- VariantProps<typeof buttonVariants> & {
110
- asChild?: boolean;
111
- }) {
102
+ const Button = React.forwardRef<
103
+ HTMLButtonElement,
104
+ React.ComponentProps<"button"> &
105
+ VariantProps<typeof buttonVariants> & {
106
+ asChild?: boolean;
107
+ }
108
+ >(function Button(
109
+ { className, variant, size, asChild = false, ...props },
110
+ ref,
111
+ ) {
112
112
  const Comp = asChild ? Slot : "button";
113
113
 
114
114
  return (
115
115
  <Comp
116
+ ref={ref}
116
117
  data-slot="button"
117
118
  className={cn(buttonVariants({ variant, size, className }))}
118
119
  {...props}
119
120
  />
120
121
  );
121
- }
122
+ });
122
123
 
123
124
  export { Button, buttonVariants };
@@ -29,6 +29,10 @@ describe("useAgent stability during runtime connection", () => {
29
29
  runtimeTransport: string;
30
30
  headers: Record<string, string>;
31
31
  agents: Record<string, AbstractAgent>;
32
+ subscribeToAgentWithOptions: (
33
+ agent: AbstractAgent,
34
+ subscriber: any,
35
+ ) => { unsubscribe: () => void };
32
36
  };
33
37
 
34
38
  beforeEach(() => {
@@ -40,6 +44,8 @@ describe("useAgent stability during runtime connection", () => {
40
44
  runtimeTransport: "rest",
41
45
  headers: {},
42
46
  agents: {},
47
+ subscribeToAgentWithOptions: (agent, subscriber) =>
48
+ agent.subscribe(subscriber),
43
49
  };
44
50
 
45
51
  mockUseCopilotKit.mockReturnValue({
@@ -47,6 +47,10 @@ describe("useAgent thread isolation", () => {
47
47
  runtimeTransport: string;
48
48
  headers: Record<string, string>;
49
49
  agents: Record<string, AbstractAgent>;
50
+ subscribeToAgentWithOptions: (
51
+ agent: AbstractAgent,
52
+ subscriber: any,
53
+ ) => { unsubscribe: () => void };
50
54
  };
51
55
 
52
56
  let registeredAgent: CloneableAgent;
@@ -64,6 +68,8 @@ describe("useAgent thread isolation", () => {
64
68
  runtimeTransport: "rest",
65
69
  headers: {},
66
70
  agents: { "my-agent": registeredAgent },
71
+ subscribeToAgentWithOptions: (agent, subscriber) =>
72
+ agent.subscribe(subscriber),
67
73
  };
68
74
 
69
75
  mockUseCopilotKit.mockReturnValue({
@@ -4,7 +4,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
4
  import { useAgent, UseAgentUpdate } from "../use-agent";
5
5
  import { useCopilotKit } from "../../providers/CopilotKitProvider";
6
6
  import { MockStepwiseAgent } from "../../__tests__/utils/test-helpers";
7
- import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
7
+ import {
8
+ CopilotKitCore,
9
+ CopilotKitCoreRuntimeConnectionStatus,
10
+ } from "@copilotkit/core";
8
11
  import type { Message } from "@ag-ui/core";
9
12
  import type { RunAgentInput } from "@ag-ui/client";
10
13
 
@@ -126,11 +129,19 @@ function createTestComponent(
126
129
  };
127
130
  }
128
131
 
129
- /** Factory for the mock return value of useCopilotKit */
132
+ /** Factory for the mock return value of useCopilotKit.
133
+ * Uses a real CopilotKitCore instance so subscribeToAgentWithOptions (with its throttle
134
+ * logic) is exercised end-to-end rather than mocked. */
130
135
  function createMockContext(
131
136
  agent: MockStepwiseAgent,
132
137
  overrides: { defaultThrottleMs?: number } = {},
133
138
  ) {
139
+ const core = new CopilotKitCore({
140
+ runtimeUrl: "http://localhost:3000/api/copilot",
141
+ });
142
+ if (overrides.defaultThrottleMs !== undefined) {
143
+ core.setDefaultThrottleMs(overrides.defaultThrottleMs);
144
+ }
134
145
  return {
135
146
  copilotkit: {
136
147
  getAgent: () => agent,
@@ -139,7 +150,8 @@ function createMockContext(
139
150
  runtimeTransport: "rest",
140
151
  headers: {},
141
152
  agents: { [String(agent.agentId)]: agent },
142
- defaultThrottleMs: overrides.defaultThrottleMs,
153
+ defaultThrottleMs: core.defaultThrottleMs,
154
+ subscribeToAgentWithOptions: core.subscribeToAgentWithOptions.bind(core),
143
155
  },
144
156
  executingToolCallIds: new Set(),
145
157
  };
@@ -313,7 +325,7 @@ describe("useAgent throttleMs", () => {
313
325
  expect(screen.getByTestId("count").textContent).toBe("3");
314
326
  });
315
327
 
316
- it("with throttleMs, onStateChanged still fires immediately", async () => {
328
+ it("with throttleMs, onStateChanged is also throttled (shared window)", async () => {
317
329
  const TestComponent = createTestComponent({
318
330
  updates: [
319
331
  UseAgentUpdate.OnMessagesChanged,
@@ -324,19 +336,29 @@ describe("useAgent throttleMs", () => {
324
336
 
325
337
  render(<TestComponent />);
326
338
 
327
- // Fire onMessagesChanged to start the throttle window
339
+ // Fire onMessagesChanged to start the throttle window (leading edge)
328
340
  act(() => {
329
341
  mockAgent.messages = [userMsg("1", "a")];
330
342
  notifyMessagesChanged(mockAgent);
331
343
  });
344
+ expect(screen.getByTestId("count").textContent).toBe("1");
332
345
 
333
- // Fire onStateChanged 10ms later — fires via microtask batch (not synchronously)
334
- await act(async () => {
346
+ // Fire onStateChanged 10ms later — should be deferred (within throttle window)
347
+ act(() => {
335
348
  vi.advanceTimersByTime(10);
336
349
  mockAgent.state = { count: 42 };
337
350
  notifyStateChanged(mockAgent);
338
351
  });
339
352
 
353
+ // State update is pending, not yet rendered
354
+ expect(screen.getByTestId("state").textContent).toBe("{}");
355
+
356
+ // Trailing edge fires after the window — await so microtask from
357
+ // batchedForceUpdate flushes and triggers the React re-render.
358
+ await act(async () => {
359
+ vi.advanceTimersByTime(100);
360
+ });
361
+
340
362
  expect(screen.getByTestId("state").textContent).toBe('{"count":42}');
341
363
  });
342
364
 
@@ -372,7 +394,7 @@ describe("useAgent throttleMs", () => {
372
394
  expect(renderCount.current).toBe(countBeforeUnmount);
373
395
  });
374
396
 
375
- it("with throttleMs and updates excluding OnMessagesChanged, throttle is a no-op", async () => {
397
+ it("with throttleMs and only OnStateChanged subscribed, first state fires on leading edge", async () => {
376
398
  const TestComponent = createTestComponent({
377
399
  updates: [UseAgentUpdate.OnStateChanged],
378
400
  throttleMs: 100,
@@ -380,7 +402,8 @@ describe("useAgent throttleMs", () => {
380
402
 
381
403
  render(<TestComponent />);
382
404
 
383
- // Only onStateChanged is subscribed fires via microtask batch
405
+ // First onStateChanged fires immediately (leading edge) await so
406
+ // microtask from batchedForceUpdate flushes.
384
407
  await act(async () => {
385
408
  mockAgent.state = { value: "test" };
386
409
  notifyStateChanged(mockAgent);
@@ -388,15 +411,13 @@ describe("useAgent throttleMs", () => {
388
411
 
389
412
  expect(screen.getByTestId("state").textContent).toBe('{"value":"test"}');
390
413
 
391
- // No onMessagesChanged subscription should exist
414
+ // No onMessagesChanged subscription should exist — messages notification
415
+ // does nothing because the handler was never registered.
392
416
  act(() => {
393
417
  mockAgent.messages = [userMsg("1", "a")];
394
418
  notifyMessagesChanged(mockAgent);
395
419
  });
396
420
 
397
- // onMessagesChanged was sent but no handler is subscribed, so no
398
- // re-render is triggered. We verify by checking state still shows the
399
- // last rendered value.
400
421
  expect(screen.getByTestId("state").textContent).toBe('{"value":"test"}');
401
422
  });
402
423
 
@@ -418,6 +439,7 @@ describe("useAgent throttleMs", () => {
418
439
  expect.stringContaining(
419
440
  "throttleMs must be a non-negative finite number",
420
441
  ),
442
+ expect.any(Error),
421
443
  );
422
444
 
423
445
  // Should behave as unthrottled — every notification fires immediately
@@ -864,23 +886,23 @@ describe("useAgent defaultThrottleMs from provider", () => {
864
886
  ])(
865
887
  "with invalid provider defaultThrottleMs ($label), falls back to unthrottled and warns",
866
888
  ({ value }) => {
889
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
890
+
867
891
  mockUseCopilotKit.mockReturnValue(
868
892
  createMockContext(mockAgent, { defaultThrottleMs: value }),
869
893
  );
870
894
 
871
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
872
895
  const TestComponent = createTestComponent({ throttleMs: undefined });
873
896
 
874
897
  render(<TestComponent />);
875
898
 
876
- expect(errorSpy).toHaveBeenCalledWith(
877
- expect.stringContaining("provider-level defaultThrottleMs"),
878
- );
899
+ // The core setter rejects invalid values and logs an error
879
900
  expect(errorSpy).toHaveBeenCalledWith(
880
901
  expect.stringContaining("must be a non-negative finite number"),
902
+ expect.any(Error),
881
903
  );
882
904
 
883
- // Should behave as unthrottled
905
+ // Should behave as unthrottled (setter rejected the value)
884
906
  act(() => {
885
907
  mockAgent.messages = [userMsg("1", "a")];
886
908
  notifyMessagesChanged(mockAgent);
@@ -963,41 +985,45 @@ describe("useAgent defaultThrottleMs from provider", () => {
963
985
  });
964
986
  });
965
987
 
966
- describe("CopilotKitCore.setDefaultThrottleMs validation", () => {
988
+ describe("CopilotKitCore.setDefaultThrottleMs", () => {
989
+ it("stores valid values", () => {
990
+ const core = new CopilotKitCore({});
991
+ core.setDefaultThrottleMs(100);
992
+ expect(core.defaultThrottleMs).toBe(100);
993
+ });
994
+
995
+ it("stores 0", () => {
996
+ const core = new CopilotKitCore({});
997
+ core.setDefaultThrottleMs(100);
998
+ core.setDefaultThrottleMs(0);
999
+ expect(core.defaultThrottleMs).toBe(0);
1000
+ });
1001
+
1002
+ it("stores undefined", () => {
1003
+ const core = new CopilotKitCore({});
1004
+ core.setDefaultThrottleMs(100);
1005
+ core.setDefaultThrottleMs(undefined);
1006
+ expect(core.defaultThrottleMs).toBeUndefined();
1007
+ });
1008
+
967
1009
  it.each([
968
1010
  { label: "NaN", value: NaN },
969
1011
  { label: "Infinity", value: Infinity },
970
1012
  { label: "-1", value: -1 },
971
1013
  { label: "-Infinity", value: -Infinity },
972
- ])("rejects invalid value ($label) and stores undefined", ({ value }) => {
973
- // Simulate the core setter behavior: invalid values are rejected
974
- // and the stored value becomes undefined (no default configured).
975
- // This is tested via the mock context to verify that the hook
976
- // correctly handles a sanitized undefined from the core.
977
- const mockAgent = new MockStepwiseAgent();
978
- mockAgent.agentId = "test-agent";
979
-
980
- // After the core setter rejects an invalid value, hooks see undefined
981
- mockUseCopilotKit.mockReturnValue(
982
- createMockContext(mockAgent, { defaultThrottleMs: undefined }),
983
- );
984
-
985
- vi.useFakeTimers();
986
- const TestComponent = createTestComponent({ throttleMs: undefined });
987
- render(<TestComponent />);
988
-
989
- // Should behave as unthrottled (no provider default in effect)
990
- act(() => {
991
- mockAgent.messages = [userMsg("1", "a")];
992
- notifyMessagesChanged(mockAgent);
993
- });
994
- expect(screen.getByTestId("count").textContent).toBe("1");
995
-
996
- act(() => {
997
- mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
998
- notifyMessagesChanged(mockAgent);
999
- });
1000
- expect(screen.getByTestId("count").textContent).toBe("2");
1001
- vi.useRealTimers();
1002
- });
1014
+ ])(
1015
+ "rejects invalid value ($label) and preserves previous value",
1016
+ ({ value }) => {
1017
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1018
+ const core = new CopilotKitCore({});
1019
+ core.setDefaultThrottleMs(200);
1020
+ core.setDefaultThrottleMs(value);
1021
+ expect(core.defaultThrottleMs).toBe(200);
1022
+ expect(errorSpy).toHaveBeenCalledWith(
1023
+ expect.stringContaining("must be a non-negative finite number"),
1024
+ expect.any(Error),
1025
+ );
1026
+ errorSpy.mockRestore();
1027
+ },
1028
+ );
1003
1029
  });
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render, act } from "@testing-library/react";
3
+ import React, { useRef } from "react";
4
+ import { usePinToSend } from "../use-pin-to-send";
5
+ import { LastUserMessageContext } from "../../components/chat/last-user-message-context";
6
+
7
+ // Small harness that wires the hook up against an in-memory DOM.
8
+ // Height mocks are applied via Object.defineProperty because jsdom doesn't run layout.
9
+ function setHeight(el: HTMLElement, height: number) {
10
+ Object.defineProperty(el, "clientHeight", {
11
+ configurable: true,
12
+ value: height,
13
+ });
14
+ Object.defineProperty(el, "offsetHeight", {
15
+ configurable: true,
16
+ value: height,
17
+ });
18
+ el.getBoundingClientRect = () =>
19
+ ({
20
+ top: 0,
21
+ left: 0,
22
+ right: 0,
23
+ bottom: height,
24
+ width: 100,
25
+ height,
26
+ x: 0,
27
+ y: 0,
28
+ toJSON: () => ({}),
29
+ }) as DOMRect;
30
+ }
31
+
32
+ // Inner component so the hook is mounted inside the Provider and can read context.
33
+ function HarnessInner({ topOffset }: { topOffset: number }) {
34
+ const scrollRef = useRef<HTMLDivElement>(null);
35
+ const contentRef = useRef<HTMLDivElement>(null);
36
+ const spacerRef = useRef<HTMLDivElement>(null);
37
+
38
+ usePinToSend({ scrollRef, contentRef, spacerRef, topOffset });
39
+
40
+ return (
41
+ <div ref={scrollRef} data-testid="scroll">
42
+ <div ref={contentRef} data-testid="content">
43
+ <div data-message-id="m1" data-role="user">
44
+ user msg 1
45
+ </div>
46
+ <div data-message-id="m2" data-role="assistant">
47
+ asst msg 1
48
+ </div>
49
+ <div data-message-id="m3" data-role="user">
50
+ user msg 2
51
+ </div>
52
+ </div>
53
+ <div ref={spacerRef} data-testid="spacer" style={{ height: 0 }} />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ function Harness({
59
+ lastUserMessage,
60
+ topOffset = 16,
61
+ }: {
62
+ lastUserMessage: { id: string | null; sendNonce: number };
63
+ topOffset?: number;
64
+ }) {
65
+ return (
66
+ <LastUserMessageContext.Provider value={lastUserMessage}>
67
+ <HarnessInner topOffset={topOffset} />
68
+ </LastUserMessageContext.Provider>
69
+ );
70
+ }
71
+
72
+ beforeEach(() => {
73
+ HTMLElement.prototype.scrollTo = vi.fn();
74
+ // jsdom does not run rAF callbacks — stub it to fire synchronously so scroll assertions work.
75
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
76
+ cb(0);
77
+ return 0;
78
+ });
79
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
80
+ });
81
+
82
+ describe("usePinToSend", () => {
83
+ it("sets spacer height to viewportHeight - userMessageHeight - topOffset on new send", async () => {
84
+ const { rerender, getByTestId } = render(
85
+ <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
86
+ );
87
+
88
+ const scroll = getByTestId("scroll");
89
+ const spacer = getByTestId("spacer");
90
+ setHeight(scroll, 800);
91
+
92
+ const userMsg = scroll.querySelector(
93
+ '[data-message-id="m3"]',
94
+ ) as HTMLElement;
95
+ setHeight(userMsg, 40);
96
+
97
+ act(() => {
98
+ rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
99
+ });
100
+
101
+ // viewport=800, userMsg=40, topOffset=16
102
+ // spacer = max(0, 800 - 40 - 16) = 744
103
+ expect(spacer.style.height).toBe("744px");
104
+ });
105
+
106
+ it("calls scrollTo with targetEl.offsetTop - topOffset on new send", async () => {
107
+ const { rerender, getByTestId } = render(
108
+ <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
109
+ );
110
+
111
+ const scroll = getByTestId("scroll");
112
+ setHeight(scroll, 800);
113
+ const scrollTo = scroll.scrollTo as unknown as ReturnType<typeof vi.fn>;
114
+
115
+ const userMsg = scroll.querySelector(
116
+ '[data-message-id="m3"]',
117
+ ) as HTMLElement;
118
+ setHeight(userMsg, 40);
119
+ // computeOffsetTop uses getBoundingClientRect; mock top=400 on userMsg and top=0 on scroll
120
+ // so that elRect.top - stopRect.top + scrollEl.scrollTop = 400 - 0 + 0 = 400.
121
+ userMsg.getBoundingClientRect = () =>
122
+ ({
123
+ top: 400,
124
+ left: 0,
125
+ right: 100,
126
+ bottom: 440,
127
+ width: 100,
128
+ height: 40,
129
+ x: 0,
130
+ y: 400,
131
+ toJSON: () => ({}),
132
+ }) as DOMRect;
133
+
134
+ act(() => {
135
+ rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
136
+ });
137
+
138
+ // Allow rAF to fire
139
+ await act(async () => {
140
+ await new Promise((r) => setTimeout(r, 0));
141
+ });
142
+
143
+ expect(scrollTo).toHaveBeenCalledWith({
144
+ top: 400 - 16,
145
+ behavior: "smooth",
146
+ });
147
+ });
148
+
149
+ it("shrinks spacer as content height grows (does not grow it)", async () => {
150
+ let observed: (() => void) | null = null;
151
+ const ROStub = vi.fn().mockImplementation((cb: () => void) => {
152
+ observed = cb;
153
+ return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() };
154
+ });
155
+ const prevRO = global.ResizeObserver;
156
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
157
+ global.ResizeObserver = ROStub as any;
158
+
159
+ try {
160
+ const { rerender, getByTestId } = render(
161
+ <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
162
+ );
163
+ const scroll = getByTestId("scroll");
164
+ const content = getByTestId("content");
165
+ const spacer = getByTestId("spacer");
166
+ setHeight(scroll, 800);
167
+ const userMsg = scroll.querySelector(
168
+ '[data-message-id="m3"]',
169
+ ) as HTMLElement;
170
+ setHeight(userMsg, 40);
171
+ setHeight(content, 200);
172
+
173
+ act(() => {
174
+ rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
175
+ });
176
+
177
+ // Initial: 800 - 40 - 16 = 744
178
+ expect(spacer.style.height).toBe("744px");
179
+
180
+ // Simulate content growing — spacer should shrink
181
+ setHeight(content, 600);
182
+ act(() => observed?.());
183
+ expect(parseInt(spacer.style.height, 10)).toBeLessThan(744);
184
+
185
+ // Simulate content shrinking — spacer should NOT grow back
186
+ setHeight(content, 100);
187
+ const shrunkHeight = spacer.style.height;
188
+ act(() => observed?.());
189
+ expect(spacer.style.height).toBe(shrunkHeight);
190
+ } finally {
191
+ global.ResizeObserver = prevRO;
192
+ }
193
+ });
194
+
195
+ it("cancels the scheduled rAF on unmount (cleanup)", async () => {
196
+ // Use a real rAF handle so we can assert the cancel was issued with it.
197
+ const cancelSpy = vi.spyOn(global, "cancelAnimationFrame");
198
+ try {
199
+ const { rerender, unmount, getByTestId } = render(
200
+ <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
201
+ );
202
+ const scroll = getByTestId("scroll");
203
+ setHeight(scroll, 800);
204
+ const userMsg = scroll.querySelector(
205
+ '[data-message-id="m3"]',
206
+ ) as HTMLElement;
207
+ setHeight(userMsg, 40);
208
+
209
+ act(() => {
210
+ rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
211
+ });
212
+
213
+ unmount();
214
+ expect(cancelSpy).toHaveBeenCalled();
215
+ } finally {
216
+ cancelSpy.mockRestore();
217
+ }
218
+ });
219
+ });
@@ -0,0 +1,55 @@
1
+ import React from "react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { renderHook } from "@testing-library/react";
4
+ import { useRenderCustomMessages } from "../use-render-custom-messages";
5
+ import { CopilotKitProvider } from "../../providers/CopilotKitProvider";
6
+ import { CopilotChatConfigurationProvider } from "../../providers/CopilotChatConfigurationProvider";
7
+
8
+ /**
9
+ * Regression test for #3497: useRenderCustomMessages throws "Agent not found"
10
+ * when the agent is undefined during the connecting state.
11
+ *
12
+ * During initial connection, the agent may not yet be registered in the
13
+ * CopilotKit registry. The hook should return null gracefully instead of
14
+ * throwing an error.
15
+ */
16
+ describe("useRenderCustomMessages (#3497)", () => {
17
+ it("returns null instead of throwing when agent is not found", () => {
18
+ // Render the hook inside a CopilotKitProvider with an agentId that
19
+ // does NOT exist in the registry (simulating connecting state).
20
+ // The hook should not throw.
21
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
22
+ <CopilotKitProvider
23
+ renderCustomMessages={[
24
+ {
25
+ agentId: "nonexistent-agent",
26
+ render: () => <div>Custom</div>,
27
+ },
28
+ ]}
29
+ >
30
+ <CopilotChatConfigurationProvider
31
+ agentId="nonexistent-agent"
32
+ threadId="test-thread"
33
+ >
34
+ {children}
35
+ </CopilotChatConfigurationProvider>
36
+ </CopilotKitProvider>
37
+ );
38
+
39
+ const { result } = renderHook(() => useRenderCustomMessages(), { wrapper });
40
+
41
+ // The hook should return a function (the render function), not throw
42
+ // When called, it should handle missing agent gracefully
43
+ if (typeof result.current === "function") {
44
+ const output = result.current({
45
+ message: { id: "msg-1", role: "assistant", content: "test" },
46
+ position: "after",
47
+ });
48
+ // Should return null since agent isn't found
49
+ expect(output).toBeNull();
50
+ } else {
51
+ // If result.current is null, that's also acceptable
52
+ expect(result.current).toBeNull();
53
+ }
54
+ });
55
+ });