@copilotkit/react-core 1.55.0-next.8 → 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.
- package/CHANGELOG.md +48 -5
- package/dist/{copilotkit-DNYSFuz5.mjs → copilotkit-BY5S1-0P.mjs} +2772 -858
- package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
- package/dist/{copilotkit-Dy5w3qEV.d.mts → copilotkit-BuhSUZHb.d.mts} +230 -17
- package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
- package/dist/{copilotkit-B3Mb1yVE.cjs → copilotkit-Bz5-ImDl.cjs} +2776 -832
- package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
- package/dist/{copilotkit-DBzgOMby.d.cts → copilotkit-dwDWYpya.d.cts} +230 -17
- package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
- package/dist/index.cjs +9 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +9 -4
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +1624 -396
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +13 -1
- package/dist/v2/index.css +1 -1
- package/dist/v2/index.d.cts +3 -3
- package/dist/v2/index.d.mts +3 -3
- package/dist/v2/index.mjs +3 -2
- package/dist/v2/index.umd.js +2746 -790
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +62 -54
- package/scripts/scope-preflight.mjs +1 -2
- package/src/components/CopilotListeners.tsx +41 -8
- package/src/components/copilot-provider/__tests__/copilot-messages-key.test.tsx +92 -0
- package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
- package/src/components/copilot-provider/copilotkit.tsx +3 -3
- package/src/components/toast/toast-provider.tsx +269 -194
- package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +27 -16
- package/src/hooks/use-copilot-chat_internal.ts +15 -4
- package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
- package/src/v2/__tests__/utils/test-helpers.tsx +107 -7
- package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
- package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
- package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
- package/src/v2/components/CopilotKitInspector.tsx +2 -0
- package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
- package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
- package/src/v2/components/chat/CopilotChat.tsx +197 -52
- package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
- package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
- package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
- package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
- package/src/v2/components/chat/CopilotChatMessageView.tsx +260 -151
- package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
- package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
- package/src/v2/components/chat/CopilotChatView.tsx +179 -66
- package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
- package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
- package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
- package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
- package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +5 -2
- package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +5 -2
- package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +60 -3
- package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
- package/src/v2/components/chat/index.ts +9 -0
- package/src/v2/components/chat/scroll-element-context.ts +13 -0
- package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +8 -0
- package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +327 -0
- package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
- package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +13 -2
- package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
- package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +23 -4
- package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
- package/src/v2/hooks/index.ts +5 -0
- package/src/v2/hooks/use-agent.tsx +220 -15
- package/src/v2/hooks/use-attachments.tsx +269 -0
- package/src/v2/hooks/use-frontend-tool.tsx +5 -2
- package/src/v2/hooks/use-render-activity-message.tsx +9 -2
- package/src/v2/hooks/use-render-custom-messages.tsx +6 -1
- package/src/v2/hooks/use-threads.tsx +35 -15
- package/src/v2/index.ts +5 -1
- package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
- package/src/v2/lib/__tests__/slots.test.ts +56 -0
- package/src/v2/lib/processPartialHtml.ts +45 -0
- package/src/v2/lib/slots.tsx +42 -1
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
- package/src/v2/providers/CopilotKitProvider.tsx +268 -32
- package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
- package/src/v2/providers/index.ts +7 -0
- package/src/v2/styles/globals.css +2 -1
- package/src/v2/types/index.ts +1 -0
- package/src/v2/types/sandbox-function.ts +11 -0
- package/dist/copilotkit-B3Mb1yVE.cjs.map +0 -1
- package/dist/copilotkit-DBzgOMby.d.cts.map +0 -1
- package/dist/copilotkit-DNYSFuz5.mjs.map +0 -1
- package/dist/copilotkit-Dy5w3qEV.d.mts.map +0 -1
- package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
|
@@ -19,10 +19,21 @@ import { CopilotChat } from "../../components/chat/CopilotChat";
|
|
|
19
19
|
* Mock agent that captures RunAgentInput to verify state is passed correctly
|
|
20
20
|
*/
|
|
21
21
|
class StateCapturingMockAgent extends MockStepwiseAgent {
|
|
22
|
-
|
|
22
|
+
// Shared via a container so the clone and original both see the same value
|
|
23
|
+
private _capture: { lastRunInput?: RunAgentInput } = {};
|
|
24
|
+
|
|
25
|
+
get lastRunInput(): RunAgentInput | undefined {
|
|
26
|
+
return this._capture.lastRunInput;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
clone(): this {
|
|
30
|
+
const cloned = super.clone();
|
|
31
|
+
(cloned as unknown as StateCapturingMockAgent)._capture = this._capture;
|
|
32
|
+
return cloned;
|
|
33
|
+
}
|
|
23
34
|
|
|
24
35
|
run(input: RunAgentInput): Observable<BaseEvent> {
|
|
25
|
-
this.lastRunInput = input;
|
|
36
|
+
this._capture.lastRunInput = input;
|
|
26
37
|
return super.run(input);
|
|
27
38
|
}
|
|
28
39
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import React, { useRef, useEffect } from "react";
|
|
2
|
+
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
import { useAttachments } from "../use-attachments";
|
|
5
|
+
|
|
6
|
+
describe("useAttachments", () => {
|
|
7
|
+
// -----------------------------------------------------------------------
|
|
8
|
+
// Referential stability — callbacks must not change between renders
|
|
9
|
+
// -----------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
describe("referential stability", () => {
|
|
12
|
+
it("all callbacks are stable across re-renders with same config", () => {
|
|
13
|
+
const config = { enabled: true, accept: "image/*" };
|
|
14
|
+
const { result, rerender } = renderHook(() => useAttachments({ config }));
|
|
15
|
+
|
|
16
|
+
const first = result.current;
|
|
17
|
+
rerender();
|
|
18
|
+
const second = result.current;
|
|
19
|
+
|
|
20
|
+
expect(second.processFiles).toBe(first.processFiles);
|
|
21
|
+
expect(second.handleFileUpload).toBe(first.handleFileUpload);
|
|
22
|
+
expect(second.handleDragOver).toBe(first.handleDragOver);
|
|
23
|
+
expect(second.handleDragLeave).toBe(first.handleDragLeave);
|
|
24
|
+
expect(second.handleDrop).toBe(first.handleDrop);
|
|
25
|
+
expect(second.removeAttachment).toBe(first.removeAttachment);
|
|
26
|
+
expect(second.consumeAttachments).toBe(first.consumeAttachments);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("callbacks remain stable when config object reference changes", () => {
|
|
30
|
+
let config = { enabled: true, accept: "image/*" };
|
|
31
|
+
const { result, rerender } = renderHook(() => useAttachments({ config }));
|
|
32
|
+
|
|
33
|
+
const first = result.current;
|
|
34
|
+
|
|
35
|
+
// Create a new config with same values — different reference
|
|
36
|
+
config = { enabled: true, accept: "image/*" };
|
|
37
|
+
rerender();
|
|
38
|
+
const second = result.current;
|
|
39
|
+
|
|
40
|
+
expect(second.processFiles).toBe(first.processFiles);
|
|
41
|
+
expect(second.handleFileUpload).toBe(first.handleFileUpload);
|
|
42
|
+
expect(second.handleDragOver).toBe(first.handleDragOver);
|
|
43
|
+
expect(second.handleDragLeave).toBe(first.handleDragLeave);
|
|
44
|
+
expect(second.handleDrop).toBe(first.handleDrop);
|
|
45
|
+
expect(second.removeAttachment).toBe(first.removeAttachment);
|
|
46
|
+
expect(second.consumeAttachments).toBe(first.consumeAttachments);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("refs are stable across re-renders", () => {
|
|
50
|
+
const { result, rerender } = renderHook(() =>
|
|
51
|
+
useAttachments({ config: undefined }),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const first = result.current;
|
|
55
|
+
rerender();
|
|
56
|
+
const second = result.current;
|
|
57
|
+
|
|
58
|
+
expect(second.fileInputRef).toBe(first.fileInputRef);
|
|
59
|
+
expect(second.containerRef).toBe(first.containerRef);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// -----------------------------------------------------------------------
|
|
64
|
+
// Re-render counting — hook should not cause unnecessary renders
|
|
65
|
+
// -----------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe("re-render counting", () => {
|
|
68
|
+
it("does not re-render when consumeAttachments is called on empty queue", () => {
|
|
69
|
+
let renderCount = 0;
|
|
70
|
+
|
|
71
|
+
const { result } = renderHook(() => {
|
|
72
|
+
renderCount++;
|
|
73
|
+
return useAttachments({ config: undefined });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const initialRenderCount = renderCount;
|
|
77
|
+
|
|
78
|
+
act(() => {
|
|
79
|
+
result.current.consumeAttachments();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// consumeAttachments on empty queue should not trigger a state update
|
|
83
|
+
expect(renderCount).toBe(initialRenderCount);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not re-render on repeated consumeAttachments with empty queue", () => {
|
|
87
|
+
let renderCount = 0;
|
|
88
|
+
|
|
89
|
+
const { result } = renderHook(() => {
|
|
90
|
+
renderCount++;
|
|
91
|
+
return useAttachments({ config: undefined });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const initialRenderCount = renderCount;
|
|
95
|
+
|
|
96
|
+
act(() => {
|
|
97
|
+
result.current.consumeAttachments();
|
|
98
|
+
result.current.consumeAttachments();
|
|
99
|
+
result.current.consumeAttachments();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(renderCount).toBe(initialRenderCount);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// -----------------------------------------------------------------------
|
|
107
|
+
// State defaults
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
describe("initial state", () => {
|
|
111
|
+
it("returns empty attachments and disabled by default", () => {
|
|
112
|
+
const { result } = renderHook(() =>
|
|
113
|
+
useAttachments({ config: undefined }),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(result.current.attachments).toEqual([]);
|
|
117
|
+
expect(result.current.enabled).toBe(false);
|
|
118
|
+
expect(result.current.dragOver).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns enabled when config.enabled is true", () => {
|
|
122
|
+
const { result } = renderHook(() =>
|
|
123
|
+
useAttachments({ config: { enabled: true } }),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(result.current.enabled).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// -----------------------------------------------------------------------
|
|
131
|
+
// consumeAttachments behavior
|
|
132
|
+
// -----------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
describe("consumeAttachments", () => {
|
|
135
|
+
it("returns empty array when no attachments", () => {
|
|
136
|
+
const { result } = renderHook(() =>
|
|
137
|
+
useAttachments({ config: undefined }),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
let consumed: any[];
|
|
141
|
+
act(() => {
|
|
142
|
+
consumed = result.current.consumeAttachments();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(consumed!).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// -----------------------------------------------------------------------
|
|
150
|
+
// removeAttachment
|
|
151
|
+
// -----------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
describe("removeAttachment", () => {
|
|
154
|
+
it("is a no-op when id does not exist", () => {
|
|
155
|
+
const { result } = renderHook(() =>
|
|
156
|
+
useAttachments({ config: undefined }),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const before = result.current.attachments;
|
|
160
|
+
|
|
161
|
+
act(() => {
|
|
162
|
+
result.current.removeAttachment("nonexistent");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Should still be empty, no crash
|
|
166
|
+
expect(result.current.attachments).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -502,13 +502,24 @@ describe("useFrontendTool E2E - Dynamic Registration", () => {
|
|
|
502
502
|
describe("Agent input plumbing", () => {
|
|
503
503
|
it("forwards registered frontend tools to runAgent input", async () => {
|
|
504
504
|
class InstrumentedMockAgent extends MockStepwiseAgent {
|
|
505
|
-
|
|
505
|
+
// Shared so the clone and original both see the captured parameters
|
|
506
|
+
private _capture: { lastRunParameters?: RunAgentParameters } = {};
|
|
507
|
+
|
|
508
|
+
get lastRunParameters(): RunAgentParameters | undefined {
|
|
509
|
+
return this._capture.lastRunParameters;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
clone(): this {
|
|
513
|
+
const cloned = super.clone();
|
|
514
|
+
(cloned as unknown as InstrumentedMockAgent)._capture = this._capture;
|
|
515
|
+
return cloned;
|
|
516
|
+
}
|
|
506
517
|
|
|
507
518
|
async runAgent(
|
|
508
519
|
parameters?: RunAgentParameters,
|
|
509
520
|
subscriber?: AgentSubscriber,
|
|
510
521
|
) {
|
|
511
|
-
this.lastRunParameters = parameters;
|
|
522
|
+
this._capture.lastRunParameters = parameters;
|
|
512
523
|
return super.runAgent(parameters, subscriber);
|
|
513
524
|
}
|
|
514
525
|
}
|
|
@@ -568,8 +579,16 @@ describe("useFrontendTool E2E - Dynamic Registration", () => {
|
|
|
568
579
|
class OneShotToolCallAgent extends AbstractAgent {
|
|
569
580
|
private runCount = 0;
|
|
570
581
|
clone(): OneShotToolCallAgent {
|
|
571
|
-
|
|
572
|
-
|
|
582
|
+
const cloned = new OneShotToolCallAgent();
|
|
583
|
+
cloned.agentId = this.agentId;
|
|
584
|
+
// Share runCount via reference so the second run emits different args
|
|
585
|
+
Object.defineProperty(cloned, "runCount", {
|
|
586
|
+
get: () => this.runCount,
|
|
587
|
+
set: (v: number) => {
|
|
588
|
+
this.runCount = v;
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
return cloned;
|
|
573
592
|
}
|
|
574
593
|
async detachActiveRun(): Promise<void> {}
|
|
575
594
|
run(_input: RunAgentInput): Observable<BaseEvent> {
|
|
@@ -383,6 +383,60 @@ describe("useThreads", () => {
|
|
|
383
383
|
});
|
|
384
384
|
});
|
|
385
385
|
|
|
386
|
+
it("exposes thread-scoped pagination properties", async () => {
|
|
387
|
+
fetchMock
|
|
388
|
+
.mockReturnValueOnce(
|
|
389
|
+
jsonResponse({
|
|
390
|
+
threads: sampleThreads,
|
|
391
|
+
joinCode: "jc-1",
|
|
392
|
+
nextCursor: "cursor-abc",
|
|
393
|
+
}),
|
|
394
|
+
)
|
|
395
|
+
.mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
|
|
396
|
+
|
|
397
|
+
const { result } = renderHook(() => useThreads(defaultInput));
|
|
398
|
+
|
|
399
|
+
await waitFor(() => {
|
|
400
|
+
expect(result.current.isLoading).toBe(false);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
expect(result.current).toHaveProperty("hasMoreThreads");
|
|
404
|
+
expect(result.current).toHaveProperty("isFetchingMoreThreads");
|
|
405
|
+
expect(result.current).toHaveProperty("fetchMoreThreads");
|
|
406
|
+
expect(result.current).not.toHaveProperty("hasNextPage");
|
|
407
|
+
expect(result.current).not.toHaveProperty("isFetchingNextPage");
|
|
408
|
+
expect(result.current).not.toHaveProperty("fetchNextPage");
|
|
409
|
+
|
|
410
|
+
expect(result.current.hasMoreThreads).toBe(true);
|
|
411
|
+
expect(result.current.isFetchingMoreThreads).toBe(false);
|
|
412
|
+
expect(typeof result.current.fetchMoreThreads).toBe("function");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("does not expose organizationId or createdById on threads", async () => {
|
|
416
|
+
fetchMock
|
|
417
|
+
.mockReturnValueOnce(
|
|
418
|
+
jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
|
|
419
|
+
)
|
|
420
|
+
.mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
|
|
421
|
+
|
|
422
|
+
const { result } = renderHook(() => useThreads(defaultInput));
|
|
423
|
+
|
|
424
|
+
await waitFor(() => {
|
|
425
|
+
expect(result.current.isLoading).toBe(false);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
for (const thread of result.current.threads) {
|
|
429
|
+
expect(thread).not.toHaveProperty("organizationId");
|
|
430
|
+
expect(thread).not.toHaveProperty("createdById");
|
|
431
|
+
expect(thread).toHaveProperty("id");
|
|
432
|
+
expect(thread).toHaveProperty("agentId");
|
|
433
|
+
expect(thread).toHaveProperty("name");
|
|
434
|
+
expect(thread).toHaveProperty("archived");
|
|
435
|
+
expect(thread).toHaveProperty("createdAt");
|
|
436
|
+
expect(thread).toHaveProperty("updatedAt");
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
386
440
|
it("tears down sockets after repeated connection failures", async () => {
|
|
387
441
|
fetchMock
|
|
388
442
|
.mockReturnValueOnce(
|
package/src/v2/hooks/index.ts
CHANGED
|
@@ -16,3 +16,8 @@ export { useInterrupt } from "./use-interrupt";
|
|
|
16
16
|
export type { UseInterruptConfig } from "./use-interrupt";
|
|
17
17
|
export { useThreads } from "./use-threads";
|
|
18
18
|
export type { Thread, UseThreadsInput, UseThreadsResult } from "./use-threads";
|
|
19
|
+
export { useAttachments } from "./use-attachments";
|
|
20
|
+
export type {
|
|
21
|
+
UseAttachmentsProps,
|
|
22
|
+
UseAttachmentsReturn,
|
|
23
|
+
} from "./use-attachments";
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCopilotKit } from "../providers/CopilotKitProvider";
|
|
2
|
+
import { useCopilotChatConfiguration } from "../providers/CopilotChatConfigurationProvider";
|
|
2
3
|
import { useMemo, useEffect, useReducer, useRef } from "react";
|
|
3
4
|
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
|
|
4
|
-
import { AbstractAgent } from "@ag-ui/client";
|
|
5
|
+
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
|
5
6
|
import {
|
|
6
7
|
ProxiedCopilotRuntimeAgent,
|
|
7
8
|
CopilotKitCoreRuntimeConnectionStatus,
|
|
@@ -21,13 +22,132 @@ const ALL_UPDATES: UseAgentUpdate[] = [
|
|
|
21
22
|
|
|
22
23
|
export interface UseAgentProps {
|
|
23
24
|
agentId?: string;
|
|
25
|
+
threadId?: string;
|
|
24
26
|
updates?: UseAgentUpdate[];
|
|
27
|
+
/**
|
|
28
|
+
* Throttle interval (in milliseconds) for React re-renders triggered by
|
|
29
|
+
* `OnMessagesChanged` notifications. Useful to reduce re-render frequency
|
|
30
|
+
* during high-frequency message updates such as streaming.
|
|
31
|
+
*
|
|
32
|
+
* Uses leading+trailing: first update fires immediately, subsequent updates
|
|
33
|
+
* within the window are coalesced, and a trailing timer ensures the most
|
|
34
|
+
* recent update fires after the window expires. The trailing edge restarts
|
|
35
|
+
* the throttle window, so no two renders occur within `throttleMs` of each
|
|
36
|
+
* other. Cleanup on unmount cancels any pending trailing timer.
|
|
37
|
+
*
|
|
38
|
+
* Must be a non-negative finite number. Negative or non-finite values fall
|
|
39
|
+
* back to unthrottled behavior with a `console.error`. Only affects
|
|
40
|
+
* `OnMessagesChanged` updates — `OnStateChanged` and `OnRunStatusChanged`
|
|
41
|
+
* always fire immediately. If `updates` does not include
|
|
42
|
+
* `OnMessagesChanged`, this property has no effect.
|
|
43
|
+
*
|
|
44
|
+
* Default: `0` (no throttle).
|
|
45
|
+
*/
|
|
46
|
+
throttleMs?: number;
|
|
25
47
|
}
|
|
26
48
|
|
|
27
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Clone a registry agent for per-thread isolation.
|
|
51
|
+
* Copies agent configuration (transport, headers, etc.) but resets conversation
|
|
52
|
+
* state (messages, threadId, state) so each thread starts fresh.
|
|
53
|
+
*/
|
|
54
|
+
function cloneForThread(
|
|
55
|
+
source: AbstractAgent,
|
|
56
|
+
threadId: string,
|
|
57
|
+
headers: Record<string, string>,
|
|
58
|
+
): AbstractAgent {
|
|
59
|
+
const clone = source.clone();
|
|
60
|
+
if (clone === source) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`useAgent: ${source.constructor.name}.clone() returned the same instance. ` +
|
|
63
|
+
`clone() must return a new, independent object.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
clone.threadId = threadId;
|
|
67
|
+
clone.setMessages([]);
|
|
68
|
+
clone.setState({});
|
|
69
|
+
if (clone instanceof HttpAgent) {
|
|
70
|
+
clone.headers = { ...headers };
|
|
71
|
+
}
|
|
72
|
+
return clone;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Module-level WeakMap: registryAgent → (threadId → clone).
|
|
77
|
+
* Shared across all useAgent() calls so that every component using the same
|
|
78
|
+
* (agentId, threadId) pair receives the same agent instance. Using WeakMap
|
|
79
|
+
* ensures the clone map is garbage-collected when the registry agent is
|
|
80
|
+
* replaced (e.g. after reconnect or hot-reload).
|
|
81
|
+
*/
|
|
82
|
+
export const globalThreadCloneMap = new WeakMap<
|
|
83
|
+
AbstractAgent,
|
|
84
|
+
Map<string, AbstractAgent>
|
|
85
|
+
>();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Look up an existing per-thread clone without creating one.
|
|
89
|
+
* Returns undefined when no clone has been created yet for this pair.
|
|
90
|
+
*/
|
|
91
|
+
export function getThreadClone(
|
|
92
|
+
registryAgent: AbstractAgent | undefined | null,
|
|
93
|
+
threadId: string | undefined | null,
|
|
94
|
+
): AbstractAgent | undefined {
|
|
95
|
+
if (!registryAgent || !threadId) return undefined;
|
|
96
|
+
return globalThreadCloneMap.get(registryAgent)?.get(threadId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getOrCreateThreadClone(
|
|
100
|
+
existing: AbstractAgent,
|
|
101
|
+
threadId: string,
|
|
102
|
+
headers: Record<string, string>,
|
|
103
|
+
): AbstractAgent {
|
|
104
|
+
let byThread = globalThreadCloneMap.get(existing);
|
|
105
|
+
if (!byThread) {
|
|
106
|
+
byThread = new Map();
|
|
107
|
+
globalThreadCloneMap.set(existing, byThread);
|
|
108
|
+
}
|
|
109
|
+
const cached = byThread.get(threadId);
|
|
110
|
+
if (cached) return cached;
|
|
111
|
+
|
|
112
|
+
const clone = cloneForThread(existing, threadId, headers);
|
|
113
|
+
byThread.set(threadId, clone);
|
|
114
|
+
return clone;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function useAgent({
|
|
118
|
+
agentId,
|
|
119
|
+
threadId,
|
|
120
|
+
updates,
|
|
121
|
+
throttleMs,
|
|
122
|
+
}: UseAgentProps = {}) {
|
|
28
123
|
agentId ??= DEFAULT_AGENT_ID;
|
|
29
124
|
|
|
30
125
|
const { copilotkit } = useCopilotKit();
|
|
126
|
+
const providerThrottleMs = copilotkit.defaultThrottleMs;
|
|
127
|
+
// Fall back to the enclosing CopilotChatConfigurationProvider's threadId so
|
|
128
|
+
// that useAgent() called without explicit threadId (e.g. inside a custom
|
|
129
|
+
// message renderer) automatically uses the same per-thread clone as the
|
|
130
|
+
// CopilotChat component it lives within.
|
|
131
|
+
const chatConfig = useCopilotChatConfiguration();
|
|
132
|
+
threadId ??= chatConfig?.threadId;
|
|
133
|
+
|
|
134
|
+
const effectiveThrottleMs = useMemo(() => {
|
|
135
|
+
const resolved = throttleMs ?? providerThrottleMs ?? 0;
|
|
136
|
+
if (!Number.isFinite(resolved) || resolved < 0) {
|
|
137
|
+
// When both throttleMs and providerThrottleMs are undefined, resolved
|
|
138
|
+
// is 0 which passes validation — so one of them must be defined here.
|
|
139
|
+
const source =
|
|
140
|
+
throttleMs !== undefined
|
|
141
|
+
? "hook-level throttleMs"
|
|
142
|
+
: "provider-level defaultThrottleMs";
|
|
143
|
+
console.error(
|
|
144
|
+
`useAgent: ${source} must be a non-negative finite number, got ${resolved}. Falling back to unthrottled.`,
|
|
145
|
+
);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
return resolved;
|
|
149
|
+
}, [throttleMs, providerThrottleMs]);
|
|
150
|
+
|
|
31
151
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
|
32
152
|
|
|
33
153
|
const updateFlags = useMemo(
|
|
@@ -43,11 +163,29 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
43
163
|
);
|
|
44
164
|
|
|
45
165
|
const agent: AbstractAgent = useMemo(() => {
|
|
166
|
+
// Use a composite key when threadId is provided so that different threads
|
|
167
|
+
// for the same agent get independent instances.
|
|
168
|
+
const cacheKey = threadId ? `${agentId}:${threadId}` : agentId;
|
|
169
|
+
|
|
46
170
|
const existing = copilotkit.getAgent(agentId);
|
|
47
171
|
if (existing) {
|
|
48
|
-
// Real agent found — clear any cached
|
|
172
|
+
// Real agent found — clear any cached provisionals for this key and the
|
|
173
|
+
// bare agentId key (handles the case where a provisional was created
|
|
174
|
+
// before threadId was available, then the component re-renders with one).
|
|
175
|
+
provisionalAgentCache.current.delete(cacheKey);
|
|
49
176
|
provisionalAgentCache.current.delete(agentId);
|
|
50
|
-
|
|
177
|
+
|
|
178
|
+
if (!threadId) {
|
|
179
|
+
// No threadId — return the shared registry agent (original behavior)
|
|
180
|
+
return existing;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// threadId provided — return the shared per-thread clone.
|
|
184
|
+
// The global WeakMap ensures all components using the same
|
|
185
|
+
// (registryAgent, threadId) pair receive the same instance, so state
|
|
186
|
+
// mutations (addMessage, setState) are visible everywhere. The WeakMap
|
|
187
|
+
// entry is GC-collected automatically when the registry agent is replaced.
|
|
188
|
+
return getOrCreateThreadClone(existing, threadId, copilotkit.headers);
|
|
51
189
|
}
|
|
52
190
|
|
|
53
191
|
const isRuntimeConfigured = copilotkit.runtimeUrl !== undefined;
|
|
@@ -60,7 +198,7 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
60
198
|
status === CopilotKitCoreRuntimeConnectionStatus.Connecting)
|
|
61
199
|
) {
|
|
62
200
|
// Return cached provisional if available (keeps reference stable)
|
|
63
|
-
const cached = provisionalAgentCache.current.get(
|
|
201
|
+
const cached = provisionalAgentCache.current.get(cacheKey);
|
|
64
202
|
if (cached) {
|
|
65
203
|
// Update headers on the cached agent in case they changed
|
|
66
204
|
cached.headers = { ...copilotkit.headers };
|
|
@@ -75,7 +213,10 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
75
213
|
});
|
|
76
214
|
// Apply current headers so runs/connects inherit them
|
|
77
215
|
provisional.headers = { ...copilotkit.headers };
|
|
78
|
-
|
|
216
|
+
if (threadId) {
|
|
217
|
+
provisional.threadId = threadId;
|
|
218
|
+
}
|
|
219
|
+
provisionalAgentCache.current.set(cacheKey, provisional);
|
|
79
220
|
return provisional;
|
|
80
221
|
}
|
|
81
222
|
|
|
@@ -88,6 +229,14 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
88
229
|
isRuntimeConfigured &&
|
|
89
230
|
status === CopilotKitCoreRuntimeConnectionStatus.Error
|
|
90
231
|
) {
|
|
232
|
+
// Cache the provisional so that dep changes while in Error state (e.g.
|
|
233
|
+
// headers update) return the same agent reference, matching the
|
|
234
|
+
// Disconnected/Connecting path and preventing spurious re-subscriptions.
|
|
235
|
+
const cached = provisionalAgentCache.current.get(cacheKey);
|
|
236
|
+
if (cached) {
|
|
237
|
+
cached.headers = { ...copilotkit.headers };
|
|
238
|
+
return cached;
|
|
239
|
+
}
|
|
91
240
|
const provisional = new ProxiedCopilotRuntimeAgent({
|
|
92
241
|
runtimeUrl: copilotkit.runtimeUrl,
|
|
93
242
|
agentId,
|
|
@@ -95,6 +244,10 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
95
244
|
runtimeMode: "pending",
|
|
96
245
|
});
|
|
97
246
|
provisional.headers = { ...copilotkit.headers };
|
|
247
|
+
if (threadId) {
|
|
248
|
+
provisional.threadId = threadId;
|
|
249
|
+
}
|
|
250
|
+
provisionalAgentCache.current.set(cacheKey, provisional);
|
|
98
251
|
return provisional;
|
|
99
252
|
}
|
|
100
253
|
|
|
@@ -113,6 +266,7 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
113
266
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
114
267
|
}, [
|
|
115
268
|
agentId,
|
|
269
|
+
threadId,
|
|
116
270
|
copilotkit.agents,
|
|
117
271
|
copilotkit.runtimeConnectionStatus,
|
|
118
272
|
copilotkit.runtimeUrl,
|
|
@@ -121,17 +275,52 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
121
275
|
]);
|
|
122
276
|
|
|
123
277
|
useEffect(() => {
|
|
124
|
-
if (updateFlags.length === 0)
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
278
|
+
if (updateFlags.length === 0) return;
|
|
127
279
|
|
|
128
280
|
const handlers: Parameters<AbstractAgent["subscribe"]>[0] = {};
|
|
281
|
+
let timerId: ReturnType<typeof setTimeout> | null = null;
|
|
282
|
+
let active = true;
|
|
129
283
|
|
|
130
284
|
if (updateFlags.includes(UseAgentUpdate.OnMessagesChanged)) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
285
|
+
const ms = effectiveThrottleMs;
|
|
286
|
+
if (ms > 0) {
|
|
287
|
+
// Throttled onMessagesChanged: leading+trailing pattern.
|
|
288
|
+
// First notification fires immediately, subsequent ones within the
|
|
289
|
+
// window are coalesced. Trailing timer fires after the window to
|
|
290
|
+
// ensure the final state is rendered.
|
|
291
|
+
let throttleActive = false;
|
|
292
|
+
// Tracks whether a notification arrived during the throttle window,
|
|
293
|
+
// so the trailing timer knows whether a re-render is needed.
|
|
294
|
+
let pending = false;
|
|
295
|
+
|
|
296
|
+
const throttledNotify = () => {
|
|
297
|
+
if (!active) return;
|
|
298
|
+
if (!throttleActive) {
|
|
299
|
+
// Leading edge — fire immediately and start the throttle window
|
|
300
|
+
throttleActive = true;
|
|
301
|
+
pending = false;
|
|
302
|
+
forceUpdate();
|
|
303
|
+
timerId = setTimeout(function trailingEdge() {
|
|
304
|
+
timerId = null;
|
|
305
|
+
if (active && pending) {
|
|
306
|
+
// Trailing edge — fire and restart the window
|
|
307
|
+
pending = false;
|
|
308
|
+
forceUpdate();
|
|
309
|
+
timerId = setTimeout(trailingEdge, ms);
|
|
310
|
+
} else {
|
|
311
|
+
// No pending notifications — end the window
|
|
312
|
+
throttleActive = false;
|
|
313
|
+
}
|
|
314
|
+
}, ms);
|
|
315
|
+
} else {
|
|
316
|
+
pending = true;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
handlers.onMessagesChanged = throttledNotify;
|
|
321
|
+
} else {
|
|
322
|
+
handlers.onMessagesChanged = forceUpdate;
|
|
323
|
+
}
|
|
135
324
|
}
|
|
136
325
|
|
|
137
326
|
if (updateFlags.includes(UseAgentUpdate.OnStateChanged)) {
|
|
@@ -145,9 +334,25 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
145
334
|
}
|
|
146
335
|
|
|
147
336
|
const subscription = agent.subscribe(handlers);
|
|
148
|
-
return () =>
|
|
337
|
+
return () => {
|
|
338
|
+
active = false;
|
|
339
|
+
if (timerId !== null) {
|
|
340
|
+
clearTimeout(timerId);
|
|
341
|
+
}
|
|
342
|
+
subscription.unsubscribe();
|
|
343
|
+
};
|
|
344
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
345
|
+
}, [agent, forceUpdate, effectiveThrottleMs, updateFlags]);
|
|
346
|
+
|
|
347
|
+
// Keep HttpAgent headers fresh without mutating inside useMemo, which is
|
|
348
|
+
// unsafe in concurrent mode (React may invoke useMemo multiple times and
|
|
349
|
+
// discard intermediate results, but mutations always land).
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
if (agent instanceof HttpAgent) {
|
|
352
|
+
agent.headers = { ...copilotkit.headers };
|
|
353
|
+
}
|
|
149
354
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
150
|
-
}, [agent,
|
|
355
|
+
}, [agent, JSON.stringify(copilotkit.headers)]);
|
|
151
356
|
|
|
152
357
|
return {
|
|
153
358
|
agent,
|