@copilotkit/react-core 1.56.5 → 1.57.0-canary.1778078321
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/dist/{copilotkit-DFaI4j2r.d.mts → copilotkit-BNlJq5UO.d.mts} +60 -6
- package/dist/copilotkit-BNlJq5UO.d.mts.map +1 -0
- package/dist/{copilotkit-Dg4r4Gi_.d.cts → copilotkit-DgC5oCFO.d.cts} +60 -6
- package/dist/copilotkit-DgC5oCFO.d.cts.map +1 -0
- package/dist/{copilotkit-OmIUrWym.mjs → copilotkit-DhuXdtE1.mjs} +317 -176
- package/dist/copilotkit-DhuXdtE1.mjs.map +1 -0
- package/dist/{copilotkit-DMFu29Kx.cjs → copilotkit-XGd8L2jL.cjs} +322 -175
- package/dist/copilotkit-XGd8L2jL.cjs.map +1 -0
- package/dist/index.cjs +2 -5
- 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 +2 -5
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +117 -172
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +2 -1
- package/dist/v2/index.css +1 -1
- package/dist/v2/index.d.cts +2 -2
- package/dist/v2/index.d.mts +2 -2
- package/dist/v2/index.mjs +2 -2
- package/dist/v2/index.umd.js +332 -181
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +5 -6
- package/src/hooks/use-copilot-chat_internal.ts +0 -1
- package/src/v2/components/chat/CopilotChat.tsx +2 -1
- package/src/v2/components/chat/CopilotChatMessageView.tsx +24 -9
- package/src/v2/components/chat/CopilotChatView.tsx +2 -2
- package/src/v2/components/chat/__tests__/CopilotChat.welcomeGate.test.tsx +1 -3
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +29 -25
- package/src/v2/components/chat/__tests__/MCPAppsUiMessage.e2e.test.tsx +5 -60
- package/src/v2/components/index.ts +1 -0
- package/src/v2/components/intelligence-indicator/IntelligenceIndicator.tsx +284 -0
- package/src/v2/components/intelligence-indicator/__tests__/IntelligenceIndicator.e2e.test.tsx +464 -0
- package/src/v2/components/intelligence-indicator/index.ts +2 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +229 -27
- package/src/v2/hooks/use-agent.tsx +7 -116
- package/src/v2/hooks/use-render-activity-message.tsx +3 -11
- package/src/v2/hooks/use-render-custom-messages.tsx +1 -6
- package/src/v2/hooks/use-threads.tsx +7 -1
- package/src/v2/styles/globals.css +118 -0
- package/dist/copilotkit-DFaI4j2r.d.mts.map +0 -1
- package/dist/copilotkit-DMFu29Kx.cjs.map +0 -1
- package/dist/copilotkit-Dg4r4Gi_.d.cts.map +0 -1
- package/dist/copilotkit-OmIUrWym.mjs.map +0 -1
- package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +0 -333
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@copilotkit/react-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.57.0-canary.1778078321",
|
|
4
4
|
"private": false,
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -73,11 +73,11 @@
|
|
|
73
73
|
"untruncate-json": "^0.0.1",
|
|
74
74
|
"use-stick-to-bottom": "^1.1.1",
|
|
75
75
|
"zod-to-json-schema": "^3.24.5",
|
|
76
|
-
"@copilotkit/a2ui-renderer": "1.
|
|
77
|
-
"@copilotkit/
|
|
78
|
-
"@copilotkit/
|
|
79
|
-
"@copilotkit/
|
|
80
|
-
"@copilotkit/
|
|
76
|
+
"@copilotkit/a2ui-renderer": "1.57.0-canary.1778078321",
|
|
77
|
+
"@copilotkit/runtime-client-gql": "1.57.0-canary.1778078321",
|
|
78
|
+
"@copilotkit/core": "1.57.0-canary.1778078321",
|
|
79
|
+
"@copilotkit/shared": "1.57.0-canary.1778078321",
|
|
80
|
+
"@copilotkit/web-inspector": "1.57.0-canary.1778078321"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@tailwindcss/cli": "^4.1.11",
|
|
@@ -179,10 +179,9 @@ describe("useCopilotChatInternal – connectAgent guard", () => {
|
|
|
179
179
|
});
|
|
180
180
|
|
|
181
181
|
it("does not call connectAgent when threadId matches (same agent instance, no re-render)", async () => {
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
// agent do not trigger another connect.
|
|
182
|
+
// The wrapper guards via lastConnectedAgentRef: connect fires once per
|
|
183
|
+
// agent instance, not once per render. After the first connect, further
|
|
184
|
+
// re-renders with the same agent do not trigger another connect.
|
|
186
185
|
mockRuntimeConnectionStatus =
|
|
187
186
|
CopilotKitCoreRuntimeConnectionStatus.Connected;
|
|
188
187
|
mockAgent.threadId = "config-thread-id";
|
|
@@ -230,13 +229,13 @@ describe("useCopilotChatInternal – connectAgent guard", () => {
|
|
|
230
229
|
});
|
|
231
230
|
});
|
|
232
231
|
|
|
233
|
-
it("passes
|
|
232
|
+
it("passes resolved agentId to useAgent", () => {
|
|
234
233
|
applyMocks();
|
|
235
234
|
|
|
236
235
|
renderHook(() => useCopilotChatInternal(), { wrapper: createWrapper() });
|
|
237
236
|
|
|
238
237
|
expect(vi.mocked(useAgent)).toHaveBeenCalledWith(
|
|
239
|
-
expect.objectContaining({
|
|
238
|
+
expect.objectContaining({ agentId: "test-agent" }),
|
|
240
239
|
);
|
|
241
240
|
});
|
|
242
241
|
});
|
|
@@ -339,7 +339,6 @@ export function useCopilotChatInternal({
|
|
|
339
339
|
const resolvedAgentId = existingConfig?.agentId ?? "default";
|
|
340
340
|
const { agent } = useAgent({
|
|
341
341
|
agentId: resolvedAgentId,
|
|
342
|
-
threadId: existingConfig?.threadId,
|
|
343
342
|
});
|
|
344
343
|
|
|
345
344
|
// Track the last agent instance we called connect() on. Without this,
|
|
@@ -118,7 +118,6 @@ export function CopilotChat({
|
|
|
118
118
|
|
|
119
119
|
const { agent } = useAgent({
|
|
120
120
|
agentId: resolvedAgentId,
|
|
121
|
-
threadId: resolvedThreadId,
|
|
122
121
|
throttleMs,
|
|
123
122
|
});
|
|
124
123
|
const { copilotkit } = useCopilotKit();
|
|
@@ -235,6 +234,8 @@ export function CopilotChat({
|
|
|
235
234
|
agent.abortController = connectAbortController;
|
|
236
235
|
}
|
|
237
236
|
|
|
237
|
+
agent.threadId = resolvedThreadId;
|
|
238
|
+
|
|
238
239
|
const connect = async (agent: AbstractAgent) => {
|
|
239
240
|
try {
|
|
240
241
|
await copilotkit.connectAgent({ agent });
|
|
@@ -8,11 +8,12 @@ import React, {
|
|
|
8
8
|
} from "react";
|
|
9
9
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
10
10
|
import { ScrollElementContext } from "./scroll-element-context";
|
|
11
|
-
import { WithSlots
|
|
11
|
+
import type { WithSlots } from "../../lib/slots";
|
|
12
|
+
import { renderSlot, isReactComponentType } from "../../lib/slots";
|
|
12
13
|
import CopilotChatAssistantMessage from "./CopilotChatAssistantMessage";
|
|
13
14
|
import CopilotChatUserMessage from "./CopilotChatUserMessage";
|
|
14
15
|
import CopilotChatReasoningMessage from "./CopilotChatReasoningMessage";
|
|
15
|
-
import {
|
|
16
|
+
import type {
|
|
16
17
|
ActivityMessage,
|
|
17
18
|
AssistantMessage,
|
|
18
19
|
Message,
|
|
@@ -22,9 +23,10 @@ import {
|
|
|
22
23
|
} from "@ag-ui/core";
|
|
23
24
|
import { twMerge } from "tailwind-merge";
|
|
24
25
|
import { useRenderActivityMessage, useRenderCustomMessages } from "../../hooks";
|
|
25
|
-
import { getThreadClone } from "../../hooks/use-agent";
|
|
26
26
|
import { useCopilotKit } from "../../providers/CopilotKitProvider";
|
|
27
27
|
import { useCopilotChatConfiguration } from "../../providers/CopilotChatConfigurationProvider";
|
|
28
|
+
import { IntelligenceIndicator } from "../intelligence-indicator";
|
|
29
|
+
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Resolves a slot value into a { Component, slotProps } pair, handling the three
|
|
@@ -392,18 +394,14 @@ export function CopilotChatMessageView({
|
|
|
392
394
|
// Subscribe to state changes so custom message renderers re-render when state updates.
|
|
393
395
|
useEffect(() => {
|
|
394
396
|
if (!config?.agentId) return;
|
|
395
|
-
const
|
|
396
|
-
// Prefer the per-thread clone so that state changes from the running agent
|
|
397
|
-
// (which is the clone, not the registry) trigger re-renders.
|
|
398
|
-
const agent =
|
|
399
|
-
getThreadClone(registryAgent, config.threadId) ?? registryAgent;
|
|
397
|
+
const agent = copilotkit.getAgent(config.agentId);
|
|
400
398
|
if (!agent) return;
|
|
401
399
|
|
|
402
400
|
const subscription = agent.subscribe({
|
|
403
401
|
onStateChanged: forceUpdate,
|
|
404
402
|
});
|
|
405
403
|
return () => subscription.unsubscribe();
|
|
406
|
-
}, [config?.agentId,
|
|
404
|
+
}, [config?.agentId, copilotkit, forceUpdate]);
|
|
407
405
|
|
|
408
406
|
// Subscribe to interrupt element changes for in-chat rendering.
|
|
409
407
|
const [interruptElement, setInterruptElement] =
|
|
@@ -608,6 +606,23 @@ export function CopilotChatMessageView({
|
|
|
608
606
|
);
|
|
609
607
|
}
|
|
610
608
|
|
|
609
|
+
// Auto-mount the IntelligenceIndicator on assistant message slots
|
|
610
|
+
// when the runtime is in intelligence mode. The component self-gates
|
|
611
|
+
// further (latest matching-assistant slot, pending tool-call grace
|
|
612
|
+
// window) so only one pill renders at a time — mounting only for
|
|
613
|
+
// assistant messages avoids the per-slot `useAgent` subscription
|
|
614
|
+
// and four effects on user/reasoning/activity slots that would just
|
|
615
|
+
// return null at the role gate anyway.
|
|
616
|
+
if (copilotkit.intelligence !== undefined && message.role === "assistant") {
|
|
617
|
+
elements.push(
|
|
618
|
+
<IntelligenceIndicator
|
|
619
|
+
key={`${message.id}-intelligence`}
|
|
620
|
+
message={message}
|
|
621
|
+
agentId={config?.agentId ?? DEFAULT_AGENT_ID}
|
|
622
|
+
/>,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
611
626
|
return elements.filter(Boolean) as React.ReactElement[];
|
|
612
627
|
};
|
|
613
628
|
|
|
@@ -89,8 +89,8 @@ export type CopilotChatViewProps = WithSlots<
|
|
|
89
89
|
/**
|
|
90
90
|
* When `true`, suppresses the welcome screen while a thread's initial
|
|
91
91
|
* connect is in flight. Prevents the "How can I help you today?" flash
|
|
92
|
-
* that would otherwise appear between mounting an empty
|
|
93
|
-
* the bootstrap messages arriving from /connect.
|
|
92
|
+
* that would otherwise appear between mounting an empty agent instance
|
|
93
|
+
* and the bootstrap messages arriving from /connect.
|
|
94
94
|
*/
|
|
95
95
|
isConnecting?: boolean;
|
|
96
96
|
/**
|
|
@@ -9,8 +9,7 @@ import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Mock agent that records every connectAgent() invocation and resolves
|
|
12
|
-
* immediately with an empty run result.
|
|
13
|
-
* per-thread clones (from useAgent's WeakMap) share the counter.
|
|
12
|
+
* immediately with an empty run result.
|
|
14
13
|
*/
|
|
15
14
|
class TrackingAgent extends MockStepwiseAgent {
|
|
16
15
|
static connectCalls: Array<{
|
|
@@ -107,7 +106,6 @@ describe("CopilotChat welcome / connect integration", () => {
|
|
|
107
106
|
expect(TrackingAgent.connectCalls.length).toBeGreaterThan(0);
|
|
108
107
|
});
|
|
109
108
|
|
|
110
|
-
// The per-thread clone carries threadId; agentId is the default.
|
|
111
109
|
expect(
|
|
112
110
|
TrackingAgent.connectCalls.some((c) => c.threadId === "real-thread"),
|
|
113
111
|
).toBe(true);
|
|
@@ -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 type { AbstractAgent } from "@ag-ui/client";
|
|
4
5
|
import { EventType } from "@ag-ui/client";
|
|
5
6
|
import {
|
|
6
7
|
MockReconnectableAgent,
|
|
@@ -17,9 +18,7 @@ import {
|
|
|
17
18
|
CopilotKitProvider,
|
|
18
19
|
useCopilotKit,
|
|
19
20
|
} from "../../../providers";
|
|
20
|
-
import type { AbstractAgent } from "@ag-ui/client";
|
|
21
21
|
import { IntelligenceAgent } from "@copilotkit/core";
|
|
22
|
-
import { getThreadClone } from "../../../hooks/use-agent";
|
|
23
22
|
import { createA2UIMessageRenderer } from "../../../a2ui/A2UIMessageRenderer";
|
|
24
23
|
import type { Theme } from "@copilotkit/a2ui-renderer";
|
|
25
24
|
import { CopilotChat } from "..";
|
|
@@ -306,18 +305,28 @@ describe("CopilotChat activity message rendering", () => {
|
|
|
306
305
|
expect(capturedCopilotkit).toBeDefined();
|
|
307
306
|
});
|
|
308
307
|
|
|
309
|
-
it("
|
|
310
|
-
// Regression
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
308
|
+
it("activity renderers receive the agent under the config agentId, not any other registered agent", async () => {
|
|
309
|
+
// Regression: the renderer's `agent` prop must come from
|
|
310
|
+
// `copilotkit.getAgent(config.agentId)` — the local registry id from
|
|
311
|
+
// CopilotChatConfigurationProvider — not from any other agent in the
|
|
312
|
+
// registry (e.g. a sibling chat's agent).
|
|
313
|
+
//
|
|
314
|
+
// The trap this catches is a refactor where the renderer pipeline keys
|
|
315
|
+
// on something other than `config.agentId` (e.g. for proxied
|
|
316
|
+
// registrations, accidentally keying on `proxy.runtimeAgentId`). The
|
|
317
|
+
// assertion shape — "two agents in the registry, the renderer must
|
|
318
|
+
// receive the one matching config.agentId" — is generic to that bug
|
|
319
|
+
// class. The proxy-routing-specific behavior (registering a
|
|
320
|
+
// ProxiedCopilotRuntimeAgent with `runtimeAgentId !== agentId` and
|
|
321
|
+
// verifying `getAgent(agentId)` returns the proxy, not the agent at
|
|
322
|
+
// runtimeAgentId) is covered by `core-register-proxied-agent.test.ts`
|
|
323
|
+
// at the registry level.
|
|
324
|
+
const localAgent = new MockStepwiseAgent();
|
|
325
|
+
localAgent.agentId = "chat-1";
|
|
326
|
+
const otherAgent = new MockStepwiseAgent();
|
|
327
|
+
otherAgent.agentId = "default";
|
|
318
328
|
|
|
319
329
|
let capturedAgent: AbstractAgent | undefined;
|
|
320
|
-
|
|
321
330
|
const activityRenderer: ReactActivityMessageRenderer<{ label: string }> = {
|
|
322
331
|
activityType: "button-action",
|
|
323
332
|
content: z.object({ label: z.string() }),
|
|
@@ -328,9 +337,9 @@ describe("CopilotChat activity message rendering", () => {
|
|
|
328
337
|
};
|
|
329
338
|
|
|
330
339
|
renderWithCopilotKit({
|
|
331
|
-
agents: {
|
|
332
|
-
agentId,
|
|
333
|
-
threadId,
|
|
340
|
+
agents: { "chat-1": localAgent, default: otherAgent },
|
|
341
|
+
agentId: "chat-1",
|
|
342
|
+
threadId: "thread-for-action-test",
|
|
334
343
|
renderActivityMessages: [activityRenderer],
|
|
335
344
|
});
|
|
336
345
|
|
|
@@ -342,27 +351,22 @@ describe("CopilotChat activity message rendering", () => {
|
|
|
342
351
|
expect(screen.getByText("show me buttons")).toBeDefined();
|
|
343
352
|
});
|
|
344
353
|
|
|
345
|
-
|
|
346
|
-
|
|
354
|
+
localAgent.emit(runStartedEvent());
|
|
355
|
+
localAgent.emit(
|
|
347
356
|
activitySnapshotEvent({
|
|
348
357
|
messageId: testId("activity-action"),
|
|
349
358
|
activityType: "button-action",
|
|
350
359
|
content: { label: "Click Me" },
|
|
351
360
|
}),
|
|
352
361
|
);
|
|
353
|
-
|
|
362
|
+
localAgent.emit(runFinishedEvent());
|
|
354
363
|
|
|
355
364
|
await waitFor(() => {
|
|
356
365
|
expect(screen.getByTestId("action-button")).toBeDefined();
|
|
357
366
|
});
|
|
358
367
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// instance chat is rendering from.
|
|
362
|
-
const clone = getThreadClone(agent, threadId);
|
|
363
|
-
expect(clone).toBeDefined();
|
|
364
|
-
expect(capturedAgent).toBe(clone);
|
|
365
|
-
expect(capturedAgent).not.toBe(agent); // must NOT be the registry agent
|
|
368
|
+
expect(capturedAgent).toBe(localAgent);
|
|
369
|
+
expect(capturedAgent).not.toBe(otherAgent);
|
|
366
370
|
});
|
|
367
371
|
|
|
368
372
|
it("restores a completed A2UI surface after reconnect from an event-native baseline", async () => {
|
|
@@ -49,6 +49,11 @@ class MockMCPProxyAgent extends AbstractAgent {
|
|
|
49
49
|
this.runAgentResponses.set(method, response);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
addMessage(msg: Parameters<AbstractAgent["addMessage"]>[0]) {
|
|
53
|
+
this.addMessageCalls.push(msg as any);
|
|
54
|
+
return super.addMessage(msg);
|
|
55
|
+
}
|
|
56
|
+
|
|
52
57
|
emit(event: BaseEvent) {
|
|
53
58
|
if (event.type === EventType.RUN_STARTED) {
|
|
54
59
|
this.isRunning = true;
|
|
@@ -70,66 +75,6 @@ class MockMCPProxyAgent extends AbstractAgent {
|
|
|
70
75
|
});
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
clone(): MockMCPProxyAgent {
|
|
74
|
-
const cloned = new MockMCPProxyAgent();
|
|
75
|
-
cloned.agentId = this.agentId;
|
|
76
|
-
type Internal = {
|
|
77
|
-
subject: Subject<BaseEvent>;
|
|
78
|
-
runAgentCalls: Array<{ input: Partial<RunAgentInput> }>;
|
|
79
|
-
addMessageCalls: Array<{ id: string; role: string; content: string }>;
|
|
80
|
-
runAgentResponses: Map<string, unknown>;
|
|
81
|
-
};
|
|
82
|
-
(cloned as unknown as Internal).subject = (
|
|
83
|
-
this as unknown as Internal
|
|
84
|
-
).subject;
|
|
85
|
-
(cloned as unknown as Internal).runAgentCalls = (
|
|
86
|
-
this as unknown as Internal
|
|
87
|
-
).runAgentCalls;
|
|
88
|
-
(cloned as unknown as Internal).addMessageCalls = (
|
|
89
|
-
this as unknown as Internal
|
|
90
|
-
).addMessageCalls;
|
|
91
|
-
(cloned as unknown as Internal).runAgentResponses = (
|
|
92
|
-
this as unknown as Internal
|
|
93
|
-
).runAgentResponses;
|
|
94
|
-
|
|
95
|
-
const registry = this;
|
|
96
|
-
Object.defineProperty(cloned, "isRunning", {
|
|
97
|
-
get() {
|
|
98
|
-
return registry.isRunning;
|
|
99
|
-
},
|
|
100
|
-
set(v: boolean) {
|
|
101
|
-
registry.isRunning = v;
|
|
102
|
-
},
|
|
103
|
-
configurable: true,
|
|
104
|
-
enumerable: true,
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
const proto = MockMCPProxyAgent.prototype;
|
|
108
|
-
cloned.runAgent = async function (
|
|
109
|
-
input?: Partial<RunAgentInput>,
|
|
110
|
-
): Promise<RunAgentResult> {
|
|
111
|
-
const proxiedRequest = input?.forwardedProps?.__proxiedMCPRequest;
|
|
112
|
-
if (proxiedRequest) {
|
|
113
|
-
return registry.runAgent(input);
|
|
114
|
-
}
|
|
115
|
-
return proto.runAgent.call(cloned, input);
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
// Track addMessage calls on the clone (the component uses the clone)
|
|
119
|
-
const origAddMessage = cloned.addMessage.bind(cloned);
|
|
120
|
-
cloned.addMessage = function (msg: Parameters<typeof origAddMessage>[0]) {
|
|
121
|
-
registry.addMessageCalls.push(msg as any);
|
|
122
|
-
return origAddMessage(msg);
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
// Proxy run() calls so spies on the registry's run() see clone invocations
|
|
126
|
-
cloned.run = function (input: RunAgentInput): Observable<BaseEvent> {
|
|
127
|
-
return registry.run(input);
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
return cloned;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
78
|
async detachActiveRun(): Promise<void> {}
|
|
134
79
|
|
|
135
80
|
run(_input: RunAgentInput): Observable<BaseEvent> {
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { Message } from "@ag-ui/core";
|
|
3
|
+
import { useCopilotKit } from "../../providers/CopilotKitProvider";
|
|
4
|
+
import { useCopilotChatConfiguration } from "../../providers/CopilotChatConfigurationProvider";
|
|
5
|
+
import { useAgent, UseAgentUpdate } from "../../hooks/use-agent";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Grace window before showing the spinner. A matching tool call must
|
|
9
|
+
* remain unresolved (no `tool`-role result message in `agent.messages`)
|
|
10
|
+
* for at least this long before the pill appears. This filters out
|
|
11
|
+
* history-replay flashes — during `connectAgent` replay, tool calls and
|
|
12
|
+
* their results arrive back-to-back in sub-millisecond bursts, so the
|
|
13
|
+
* timer is cancelled before it fires. Live runs cross the threshold
|
|
14
|
+
* easily because the tool actually has to execute.
|
|
15
|
+
*/
|
|
16
|
+
const PENDING_THRESHOLD_MS = 100;
|
|
17
|
+
|
|
18
|
+
/** Hold the checkmark briefly before fading out. */
|
|
19
|
+
const CHECK_HOLD_MS = 800;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Duration of the fade-out animation. Must match
|
|
23
|
+
* `cpk-intelligence-pill-fade-out` keyframes in `v2/styles/globals.css`.
|
|
24
|
+
*/
|
|
25
|
+
const FADE_OUT_ANIMATION_MS = 480;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tool-name regex patterns that trigger the indicator. Currently
|
|
29
|
+
* hardcoded to the Intelligence MCP server's canonical tool name. If
|
|
30
|
+
* we add per-instance customization later (e.g. a `CopilotKitProvider`
|
|
31
|
+
* prop or a runtime-info field), this constant becomes the fallback.
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_TOOL_PATTERNS: readonly RegExp[] = [/^bash$/];
|
|
34
|
+
|
|
35
|
+
type Phase = "idle" | "spinner" | "check" | "fading" | "hidden";
|
|
36
|
+
|
|
37
|
+
export interface IntelligenceIndicatorProps {
|
|
38
|
+
/** The message this indicator is attached to. */
|
|
39
|
+
message: Message;
|
|
40
|
+
/**
|
|
41
|
+
* Agent id whose run state the indicator tracks. Pass through from
|
|
42
|
+
* the surrounding chat configuration; mounting from
|
|
43
|
+
* `CopilotChatMessageView` resolves this automatically.
|
|
44
|
+
*/
|
|
45
|
+
agentId: string;
|
|
46
|
+
/**
|
|
47
|
+
* Optional override for the visible label. Defaults to "Using
|
|
48
|
+
* CopilotKit Intelligence".
|
|
49
|
+
*/
|
|
50
|
+
label?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isMatchingToolCallName = (name: unknown): boolean =>
|
|
54
|
+
typeof name === "string" && DEFAULT_TOOL_PATTERNS.some((p) => p.test(name));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* "Tool-call-like" messages do NOT count as a real follow-up: tool
|
|
58
|
+
* result messages, assistant messages that carry tool calls, and
|
|
59
|
+
* empty-content assistant messages (which some providers emit as a
|
|
60
|
+
* standalone wrapper around a batch of tool calls). A real follow-up
|
|
61
|
+
* is anything else — most importantly an assistant message with prose
|
|
62
|
+
* content, or a fresh user message.
|
|
63
|
+
*/
|
|
64
|
+
const isToolCallLikeMessage = (m: Message): boolean => {
|
|
65
|
+
if (m.role === "tool") return true;
|
|
66
|
+
if (m.role === "assistant") {
|
|
67
|
+
const tcs = Array.isArray(m.toolCalls) ? m.toolCalls : [];
|
|
68
|
+
if (tcs.length > 0) return true;
|
|
69
|
+
const content = m.content;
|
|
70
|
+
return typeof content !== "string" || content.trim().length === 0;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The "Using CopilotKit Intelligence" pill. Auto-mounted by
|
|
77
|
+
* `CopilotChatMessageView` for every message slot when
|
|
78
|
+
* `copilotkit.intelligence` is configured — callers do not register
|
|
79
|
+
* this themselves. Self-gates so only the canonical message renders a
|
|
80
|
+
* pill.
|
|
81
|
+
*
|
|
82
|
+
* Render gates (all must hold):
|
|
83
|
+
* 1. `copilotkit.intelligence !== undefined`
|
|
84
|
+
* 2. The message is an assistant message with at least one tool call
|
|
85
|
+
* whose name matches {@link DEFAULT_TOOL_PATTERNS}
|
|
86
|
+
* 3. The message is the *latest* such matching-assistant message in
|
|
87
|
+
* `agent.messages` — tool-result messages and prose-only assistant
|
|
88
|
+
* messages don't invalidate the slot, so the pill stays
|
|
89
|
+
* continuously through a multi-step tool chain.
|
|
90
|
+
* 4. The phase machine is past `idle` (the pending-grace timer fired)
|
|
91
|
+
* and not yet `hidden`.
|
|
92
|
+
*
|
|
93
|
+
* Phase machine (per-instance, all timers local):
|
|
94
|
+
* - Starts in `idle` — nothing rendered.
|
|
95
|
+
* - `idle → spinner` once a matching tool call has been pending
|
|
96
|
+
* (no `tool`-role result with a matching `toolCallId`) for
|
|
97
|
+
* {@link PENDING_THRESHOLD_MS}. Replay flashes (tool call + result
|
|
98
|
+
* in the same tick) never cross this threshold.
|
|
99
|
+
* - `spinner → check` as soon as EITHER `agent.isRunning` flips
|
|
100
|
+
* false OR a non-tool-call-like message appears later in
|
|
101
|
+
* `agent.messages` (i.e. the agent has produced a "real"
|
|
102
|
+
* follow-up — prose answer or a new user turn).
|
|
103
|
+
* - `check → fading` after {@link CHECK_HOLD_MS}.
|
|
104
|
+
* - `fading → hidden` after {@link FADE_OUT_ANIMATION_MS}.
|
|
105
|
+
*
|
|
106
|
+
* Once `hidden`, the phase is sticky — a finished pill never re-spawns
|
|
107
|
+
* on the same message. New runs mount fresh indicator instances on
|
|
108
|
+
* their own assistant messages.
|
|
109
|
+
*
|
|
110
|
+
* The "exactly one pill at a time" guarantee is structural: only one
|
|
111
|
+
* message satisfies the latest-matching-assistant gate at any moment.
|
|
112
|
+
*/
|
|
113
|
+
export function IntelligenceIndicator(
|
|
114
|
+
props: IntelligenceIndicatorProps,
|
|
115
|
+
): React.ReactElement | null {
|
|
116
|
+
const { message, agentId, label = "Using CopilotKit Intelligence" } = props;
|
|
117
|
+
|
|
118
|
+
const { copilotkit } = useCopilotKit();
|
|
119
|
+
const config = useCopilotChatConfiguration();
|
|
120
|
+
const { agent } = useAgent({
|
|
121
|
+
agentId,
|
|
122
|
+
updates: [
|
|
123
|
+
UseAgentUpdate.OnRunStatusChanged,
|
|
124
|
+
UseAgentUpdate.OnMessagesChanged,
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// IDs of matching tool calls on this message.
|
|
129
|
+
const matchingToolCallIds = useMemo<readonly string[]>(() => {
|
|
130
|
+
if (message.role !== "assistant") return [];
|
|
131
|
+
const tcs = Array.isArray(message.toolCalls) ? message.toolCalls : [];
|
|
132
|
+
const ids: string[] = [];
|
|
133
|
+
for (const tc of tcs) {
|
|
134
|
+
if (isMatchingToolCallName(tc?.function?.name) && tc?.id) {
|
|
135
|
+
ids.push(tc.id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return ids;
|
|
139
|
+
}, [message]);
|
|
140
|
+
|
|
141
|
+
// Pending = at least one matching tool call has no corresponding
|
|
142
|
+
// `tool`-role result message in `agent.messages`.
|
|
143
|
+
const hasPending = useMemo(() => {
|
|
144
|
+
if (matchingToolCallIds.length === 0) return false;
|
|
145
|
+
const resolved = new Set<string>();
|
|
146
|
+
for (const m of agent.messages) {
|
|
147
|
+
if (m.role === "tool" && m.toolCallId) resolved.add(m.toolCallId);
|
|
148
|
+
}
|
|
149
|
+
return matchingToolCallIds.some((id) => !resolved.has(id));
|
|
150
|
+
}, [matchingToolCallIds, agent.messages]);
|
|
151
|
+
|
|
152
|
+
// True once the agent has produced a "real" message *after* this
|
|
153
|
+
// assistant message — prose, a new user turn, etc. Tool-call-like
|
|
154
|
+
// messages do not count (they're part of the same tool flow).
|
|
155
|
+
const sawRealFollowup = useMemo(() => {
|
|
156
|
+
const idx = agent.messages.findIndex((m) => m.id === message.id);
|
|
157
|
+
if (idx < 0) return false;
|
|
158
|
+
for (let i = idx + 1; i < agent.messages.length; i += 1) {
|
|
159
|
+
if (!isToolCallLikeMessage(agent.messages[i]!)) return true;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}, [agent.messages, message.id]);
|
|
163
|
+
|
|
164
|
+
const [phase, setPhase] = useState<Phase>("idle");
|
|
165
|
+
|
|
166
|
+
// idle → spinner: pending tool call hasn't been resolved within the
|
|
167
|
+
// grace window. Cleared if the result arrives first (replay) or if
|
|
168
|
+
// there's nothing to wait on.
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (phase !== "idle") return undefined;
|
|
171
|
+
if (!hasPending) return undefined;
|
|
172
|
+
const t = setTimeout(() => setPhase("spinner"), PENDING_THRESHOLD_MS);
|
|
173
|
+
return () => clearTimeout(t);
|
|
174
|
+
}, [phase, hasPending]);
|
|
175
|
+
|
|
176
|
+
// spinner → check: agent stopped running OR a real follow-up
|
|
177
|
+
// message arrived. Both are independent signals; whichever fires
|
|
178
|
+
// first wins.
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (phase !== "spinner") return undefined;
|
|
181
|
+
if (!agent.isRunning || sawRealFollowup) {
|
|
182
|
+
setPhase("check");
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}, [phase, agent.isRunning, sawRealFollowup]);
|
|
186
|
+
|
|
187
|
+
// check → fading after the hold.
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (phase !== "check") return undefined;
|
|
190
|
+
const t = setTimeout(() => setPhase("fading"), CHECK_HOLD_MS);
|
|
191
|
+
return () => clearTimeout(t);
|
|
192
|
+
}, [phase]);
|
|
193
|
+
|
|
194
|
+
// fading → hidden after the fade animation.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (phase !== "fading") return undefined;
|
|
197
|
+
const t = setTimeout(() => setPhase("hidden"), FADE_OUT_ANIMATION_MS);
|
|
198
|
+
return () => clearTimeout(t);
|
|
199
|
+
}, [phase]);
|
|
200
|
+
|
|
201
|
+
// ─── Render gates ────────────────────────────────────────────────────
|
|
202
|
+
// Hooks above MUST run unconditionally; bail with `null` only after.
|
|
203
|
+
|
|
204
|
+
if (copilotkit.intelligence === undefined) return null;
|
|
205
|
+
if (!config) return null;
|
|
206
|
+
if (phase === "idle" || phase === "hidden") return null;
|
|
207
|
+
|
|
208
|
+
if (message.role !== "assistant") return null;
|
|
209
|
+
// Defensive: a malformed `toolCalls` (non-array, missing nested
|
|
210
|
+
// `function.name`) would otherwise throw inside `.some(...)` and take
|
|
211
|
+
// down the chat tree. Treat as "no match" instead.
|
|
212
|
+
const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls : [];
|
|
213
|
+
const hasMatch = toolCalls.some((tc) =>
|
|
214
|
+
isMatchingToolCallName(tc?.function?.name),
|
|
215
|
+
);
|
|
216
|
+
if (!hasMatch) return null;
|
|
217
|
+
|
|
218
|
+
// Walk `agent.messages` from the end and find the latest assistant
|
|
219
|
+
// message that itself has a matching tool call. If that's not us,
|
|
220
|
+
// we're not the canonical slot — return `null`.
|
|
221
|
+
let latestMatchingAssistantId: string | undefined;
|
|
222
|
+
for (let i = agent.messages.length - 1; i >= 0; i -= 1) {
|
|
223
|
+
const m = agent.messages[i]!;
|
|
224
|
+
if (m.role !== "assistant") continue;
|
|
225
|
+
const tcs = Array.isArray(m.toolCalls) ? m.toolCalls : [];
|
|
226
|
+
if (tcs.some((tc) => isMatchingToolCallName(tc?.function?.name))) {
|
|
227
|
+
latestMatchingAssistantId = m.id;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (latestMatchingAssistantId !== message.id) return null;
|
|
232
|
+
|
|
233
|
+
// ─── Visual ──────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
const showSpinner = phase === "spinner";
|
|
236
|
+
const isFading = phase === "fading";
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<span
|
|
240
|
+
className={
|
|
241
|
+
"cpk-intelligence-pill" +
|
|
242
|
+
(isFading ? " cpk-intelligence-pill--fading" : "")
|
|
243
|
+
}
|
|
244
|
+
role="status"
|
|
245
|
+
aria-live="polite"
|
|
246
|
+
aria-hidden={isFading || undefined}
|
|
247
|
+
data-testid={`cpk-intelligence-pill-${message.id}`}
|
|
248
|
+
title={label}
|
|
249
|
+
>
|
|
250
|
+
<svg
|
|
251
|
+
className="cpk-intelligence-pill__icon"
|
|
252
|
+
viewBox="0 0 24 24"
|
|
253
|
+
width="14"
|
|
254
|
+
height="14"
|
|
255
|
+
aria-hidden="true"
|
|
256
|
+
>
|
|
257
|
+
<circle
|
|
258
|
+
cx="12"
|
|
259
|
+
cy="12"
|
|
260
|
+
r="9"
|
|
261
|
+
fill="none"
|
|
262
|
+
strokeWidth="2.5"
|
|
263
|
+
strokeLinecap="round"
|
|
264
|
+
className={
|
|
265
|
+
"cpk-intelligence-pill__ring" +
|
|
266
|
+
(showSpinner ? "" : " cpk-intelligence-pill__ring--done")
|
|
267
|
+
}
|
|
268
|
+
/>
|
|
269
|
+
<path
|
|
270
|
+
d="M8 12.5l3 3 5-6"
|
|
271
|
+
fill="none"
|
|
272
|
+
strokeWidth="2.5"
|
|
273
|
+
strokeLinecap="round"
|
|
274
|
+
strokeLinejoin="round"
|
|
275
|
+
className={
|
|
276
|
+
"cpk-intelligence-pill__check" +
|
|
277
|
+
(showSpinner ? "" : " cpk-intelligence-pill__check--shown")
|
|
278
|
+
}
|
|
279
|
+
/>
|
|
280
|
+
</svg>
|
|
281
|
+
<span>{label}</span>
|
|
282
|
+
</span>
|
|
283
|
+
);
|
|
284
|
+
}
|