@copilotkit/react-core 1.56.2-canary.pin-to-send → 1.56.2-canary.test-welcome-screen
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-D5JT2Pu3.cjs → copilotkit-By2G6-Zx.cjs} +22 -9
- package/dist/copilotkit-By2G6-Zx.cjs.map +1 -0
- package/dist/{copilotkit-DArT2Iuw.d.mts → copilotkit-DFaI4j2r.d.mts} +3 -1
- package/dist/copilotkit-DFaI4j2r.d.mts.map +1 -0
- package/dist/{copilotkit-KEc28l8G.d.cts → copilotkit-Dg4r4Gi_.d.cts} +3 -1
- package/dist/copilotkit-Dg4r4Gi_.d.cts.map +1 -0
- package/dist/{copilotkit-BBYbekCa.mjs → copilotkit-PzJlPKcU.mjs} +22 -9
- package/dist/copilotkit-PzJlPKcU.mjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.umd.js +15 -4
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +1 -1
- package/dist/v2/index.d.cts +1 -1
- package/dist/v2/index.d.mts +1 -1
- package/dist/v2/index.mjs +1 -1
- package/dist/v2/index.umd.js +21 -8
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/components/copilot-provider/__tests__/v1-explicit-threadid-bridge.test.tsx +107 -0
- package/src/components/copilot-provider/copilotkit.tsx +6 -1
- package/src/context/__tests__/threads-context.test.tsx +116 -3
- package/src/context/threads-context.tsx +18 -1
- package/src/v2/components/chat/CopilotChat.tsx +19 -8
- package/src/v2/components/chat/__tests__/CopilotChat.welcomeGate.test.tsx +186 -0
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +29 -1
- package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.tsx +106 -0
- package/dist/copilotkit-BBYbekCa.mjs.map +0 -1
- package/dist/copilotkit-D5JT2Pu3.cjs.map +0 -1
- package/dist/copilotkit-DArT2Iuw.d.mts.map +0 -1
- package/dist/copilotkit-KEc28l8G.d.cts.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@copilotkit/react-core",
|
|
3
|
-
"version": "1.56.2-canary.
|
|
3
|
+
"version": "1.56.2-canary.test-welcome-screen",
|
|
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/
|
|
77
|
-
"@copilotkit/
|
|
78
|
-
"@copilotkit/
|
|
79
|
-
"@copilotkit/web-inspector": "1.56.2-canary.
|
|
80
|
-
"@copilotkit/
|
|
76
|
+
"@copilotkit/a2ui-renderer": "1.56.2-canary.test-welcome-screen",
|
|
77
|
+
"@copilotkit/core": "1.56.2-canary.test-welcome-screen",
|
|
78
|
+
"@copilotkit/shared": "1.56.2-canary.test-welcome-screen",
|
|
79
|
+
"@copilotkit/web-inspector": "1.56.2-canary.test-welcome-screen",
|
|
80
|
+
"@copilotkit/runtime-client-gql": "1.56.2-canary.test-welcome-screen"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@tailwindcss/cli": "^4.1.11",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, act } from "@testing-library/react";
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
4
|
+
import { CopilotKit } from "../copilotkit";
|
|
5
|
+
import { useCopilotContext } from "../../../context/copilot-context";
|
|
6
|
+
import { useCopilotChatConfiguration } from "../../../v2";
|
|
7
|
+
import type { CopilotKitProps } from "../copilotkit-props";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Probe that reads hasExplicitThreadId from the CopilotChatConfigurationProvider
|
|
11
|
+
* that the v1 <CopilotKit> bridge renders. This is the surface CopilotChat
|
|
12
|
+
* itself reads from to gate /connect and the welcome screen.
|
|
13
|
+
*/
|
|
14
|
+
function ExplicitProbe() {
|
|
15
|
+
const config = useCopilotChatConfiguration();
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<div data-testid="explicit">{String(config?.hasExplicitThreadId)}</div>
|
|
19
|
+
<div data-testid="threadId">{config?.threadId ?? ""}</div>
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Exposes the v1 context's setThreadId so tests can drive the
|
|
26
|
+
* auto → explicit transition from outside React.
|
|
27
|
+
*/
|
|
28
|
+
function SetThreadIdButton({ nextId }: { nextId: string }) {
|
|
29
|
+
const { setThreadId } = useCopilotContext();
|
|
30
|
+
return (
|
|
31
|
+
<button data-testid="setThread" onClick={() => setThreadId(nextId)}>
|
|
32
|
+
set
|
|
33
|
+
</button>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// `agents__unsafe_dev_only` isn't declared on v1 CopilotKitProps but is
|
|
38
|
+
// forwarded via spread to the v2 provider underneath. Cast once here rather
|
|
39
|
+
// than every render.
|
|
40
|
+
type V1Props = CopilotKitProps & {
|
|
41
|
+
agents__unsafe_dev_only?: Record<string, unknown>;
|
|
42
|
+
};
|
|
43
|
+
const CopilotKitAny = CopilotKit as unknown as React.FC<V1Props>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Regression coverage for fix/welcome-not-showing-at-all at the v1 bridge
|
|
47
|
+
* boundary. The v1 <CopilotKit> wrapper pipes a ThreadsProvider-minted UUID
|
|
48
|
+
* through as `threadId`, but that UUID is NOT a caller choice — the bridge
|
|
49
|
+
* must mark it as non-explicit so downstream consumers don't treat it as a
|
|
50
|
+
* real backend thread. These tests verify the signal makes it all the way
|
|
51
|
+
* through to CopilotChatConfigurationProvider.
|
|
52
|
+
*/
|
|
53
|
+
describe("v1 <CopilotKit> bridge → hasExplicitThreadId", () => {
|
|
54
|
+
// Silence the in-dev/test "missing runtimeUrl" warning — we pass publicApiKey.
|
|
55
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
warnSpy.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("is false on mount when no threadId prop is supplied", () => {
|
|
64
|
+
render(
|
|
65
|
+
<CopilotKitAny publicApiKey="test-key">
|
|
66
|
+
<ExplicitProbe />
|
|
67
|
+
</CopilotKitAny>,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// ThreadsProvider auto-minted the UUID — it's not a caller-picked thread.
|
|
71
|
+
expect(screen.getByTestId("explicit").textContent).toBe("false");
|
|
72
|
+
// threadId still resolves to a value (mock-thread-id from setupTests),
|
|
73
|
+
// but downstream consumers must NOT treat it as real.
|
|
74
|
+
expect(screen.getByTestId("threadId").textContent).toBe("mock-thread-id");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("is true when threadId prop is supplied to <CopilotKit>", () => {
|
|
78
|
+
render(
|
|
79
|
+
<CopilotKitAny publicApiKey="test-key" threadId="caller-thread">
|
|
80
|
+
<ExplicitProbe />
|
|
81
|
+
</CopilotKitAny>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(screen.getByTestId("explicit").textContent).toBe("true");
|
|
85
|
+
expect(screen.getByTestId("threadId").textContent).toBe("caller-thread");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("flips from false to true after setThreadId() is called on the v1 context", () => {
|
|
89
|
+
render(
|
|
90
|
+
<CopilotKitAny publicApiKey="test-key">
|
|
91
|
+
<ExplicitProbe />
|
|
92
|
+
<SetThreadIdButton nextId="user-picked-thread" />
|
|
93
|
+
</CopilotKitAny>,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(screen.getByTestId("explicit").textContent).toBe("false");
|
|
97
|
+
|
|
98
|
+
act(() => {
|
|
99
|
+
screen.getByTestId("setThread").click();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(screen.getByTestId("threadId").textContent).toBe(
|
|
103
|
+
"user-picked-thread",
|
|
104
|
+
);
|
|
105
|
+
expect(screen.getByTestId("explicit").textContent).toBe("true");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -478,7 +478,11 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
|
|
|
478
478
|
}
|
|
479
479
|
}, [props.agent]);
|
|
480
480
|
|
|
481
|
-
const {
|
|
481
|
+
const {
|
|
482
|
+
threadId,
|
|
483
|
+
setThreadId: setInternalThreadId,
|
|
484
|
+
isThreadIdExplicit,
|
|
485
|
+
} = useThreads();
|
|
482
486
|
|
|
483
487
|
const setThreadId = useCallback(
|
|
484
488
|
(value: SetStateAction<string>) => {
|
|
@@ -757,6 +761,7 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
|
|
|
757
761
|
// isModalDefaultOpen={isModalDefaultOpen}
|
|
758
762
|
agentId={props.agent ?? "default"}
|
|
759
763
|
threadId={threadId}
|
|
764
|
+
hasExplicitThreadId={isThreadIdExplicit}
|
|
760
765
|
>
|
|
761
766
|
<CopilotContext.Provider value={copilotContextValue}>
|
|
762
767
|
<CopilotListeners />
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { render, screen, act } from "@testing-library/react";
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
3
4
|
import { ThreadsProvider, useThreads } from "../threads-context";
|
|
4
5
|
|
|
5
6
|
function ThreadIdViewer() {
|
|
6
|
-
const { threadId } = useThreads();
|
|
7
|
-
return
|
|
7
|
+
const { threadId, isThreadIdExplicit } = useThreads();
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
<div data-testid="threadId">{threadId}</div>
|
|
11
|
+
<div data-testid="isExplicit">{String(isThreadIdExplicit)}</div>
|
|
12
|
+
</>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Exposes setThreadId to the test so it can trigger the auto→explicit flip.
|
|
17
|
+
function ThreadIdController({ nextId }: { nextId: string }) {
|
|
18
|
+
const { setThreadId } = useThreads();
|
|
19
|
+
return (
|
|
20
|
+
<button data-testid="setThread" onClick={() => setThreadId(nextId)}>
|
|
21
|
+
set
|
|
22
|
+
</button>
|
|
23
|
+
);
|
|
8
24
|
}
|
|
9
25
|
|
|
10
26
|
describe("ThreadsProvider", () => {
|
|
@@ -25,4 +41,101 @@ describe("ThreadsProvider", () => {
|
|
|
25
41
|
|
|
26
42
|
expect(screen.getByTestId("threadId").textContent).toBe("customer-thread");
|
|
27
43
|
});
|
|
44
|
+
|
|
45
|
+
describe("isThreadIdExplicit", () => {
|
|
46
|
+
it("is false on first mount when no threadId prop is supplied", () => {
|
|
47
|
+
// Auto-minted UUID — the backend has never seen it, so downstream
|
|
48
|
+
// consumers (e.g. /connect) must NOT treat this as a real thread.
|
|
49
|
+
render(
|
|
50
|
+
<ThreadsProvider>
|
|
51
|
+
<ThreadIdViewer />
|
|
52
|
+
</ThreadsProvider>,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(screen.getByTestId("threadId").textContent).toBe("mock-thread-id");
|
|
56
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("false");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("is true when threadId prop is supplied on mount", () => {
|
|
60
|
+
render(
|
|
61
|
+
<ThreadsProvider threadId="customer-thread">
|
|
62
|
+
<ThreadIdViewer />
|
|
63
|
+
</ThreadsProvider>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(screen.getByTestId("threadId").textContent).toBe(
|
|
67
|
+
"customer-thread",
|
|
68
|
+
);
|
|
69
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("true");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("flips from false to true after setThreadId() is called", () => {
|
|
73
|
+
render(
|
|
74
|
+
<ThreadsProvider>
|
|
75
|
+
<ThreadIdViewer />
|
|
76
|
+
<ThreadIdController nextId="user-picked-thread" />
|
|
77
|
+
</ThreadsProvider>,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("false");
|
|
81
|
+
|
|
82
|
+
act(() => {
|
|
83
|
+
screen.getByTestId("setThread").click();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(screen.getByTestId("threadId").textContent).toBe(
|
|
87
|
+
"user-picked-thread",
|
|
88
|
+
);
|
|
89
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("true");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reverts to false when an explicit prop is removed and setThreadId was never called", () => {
|
|
93
|
+
// Current contract: explicitness via the `threadId` prop is prop-derived,
|
|
94
|
+
// so removing the prop returns the provider to its auto-minted state.
|
|
95
|
+
// Pinning this guards against an accidental "sticky explicit" regression.
|
|
96
|
+
const { rerender } = render(
|
|
97
|
+
<ThreadsProvider threadId="customer-thread">
|
|
98
|
+
<ThreadIdViewer />
|
|
99
|
+
</ThreadsProvider>,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("true");
|
|
103
|
+
|
|
104
|
+
rerender(
|
|
105
|
+
<ThreadsProvider>
|
|
106
|
+
<ThreadIdViewer />
|
|
107
|
+
</ThreadsProvider>,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(screen.getByTestId("threadId").textContent).toBe("mock-thread-id");
|
|
111
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("false");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("stays true after prop is removed if setThreadId was called while prop was present", () => {
|
|
115
|
+
// Once the caller has touched setThreadId, explicitness is sticky —
|
|
116
|
+
// the internal "user picked a thread" flag outlives any prop churn.
|
|
117
|
+
const { rerender } = render(
|
|
118
|
+
<ThreadsProvider threadId="customer-thread">
|
|
119
|
+
<ThreadIdViewer />
|
|
120
|
+
<ThreadIdController nextId="user-picked-thread" />
|
|
121
|
+
</ThreadsProvider>,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
act(() => {
|
|
125
|
+
screen.getByTestId("setThread").click();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
rerender(
|
|
129
|
+
<ThreadsProvider>
|
|
130
|
+
<ThreadIdViewer />
|
|
131
|
+
<ThreadIdController nextId="user-picked-thread" />
|
|
132
|
+
</ThreadsProvider>,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(screen.getByTestId("threadId").textContent).toBe(
|
|
136
|
+
"user-picked-thread",
|
|
137
|
+
);
|
|
138
|
+
expect(screen.getByTestId("isExplicit").textContent).toBe("true");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
28
141
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, {
|
|
2
2
|
createContext,
|
|
3
|
+
useCallback,
|
|
3
4
|
useContext,
|
|
4
5
|
useState,
|
|
5
6
|
ReactNode,
|
|
@@ -10,6 +11,12 @@ import { randomUUID } from "@copilotkit/shared";
|
|
|
10
11
|
export interface ThreadsContextValue {
|
|
11
12
|
threadId: string;
|
|
12
13
|
setThreadId: (value: SetStateAction<string>) => void;
|
|
14
|
+
// True when the current threadId was chosen by the caller — either via
|
|
15
|
+
// the `threadId` prop on <CopilotKit> / <ThreadsProvider>, or via a later
|
|
16
|
+
// setThreadId() call. False when the provider minted a UUID on first
|
|
17
|
+
// mount so downstream consumers don't have to treat that placeholder as
|
|
18
|
+
// a real backend thread.
|
|
19
|
+
isThreadIdExplicit: boolean;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
const ThreadsContext = createContext<ThreadsContextValue | undefined>(
|
|
@@ -25,15 +32,25 @@ export function ThreadsProvider({
|
|
|
25
32
|
children,
|
|
26
33
|
threadId: explicitThreadId,
|
|
27
34
|
}: ThreadsProviderProps) {
|
|
28
|
-
const [internalThreadId,
|
|
35
|
+
const [internalThreadId, setInternalThreadId] = useState<string>(() =>
|
|
36
|
+
randomUUID(),
|
|
37
|
+
);
|
|
38
|
+
const [internalIsExplicit, setInternalIsExplicit] = useState<boolean>(false);
|
|
29
39
|
|
|
30
40
|
const threadId = explicitThreadId ?? internalThreadId;
|
|
41
|
+
const isThreadIdExplicit = explicitThreadId != null || internalIsExplicit;
|
|
42
|
+
|
|
43
|
+
const setThreadId = useCallback((value: SetStateAction<string>) => {
|
|
44
|
+
setInternalThreadId(value);
|
|
45
|
+
setInternalIsExplicit(true);
|
|
46
|
+
}, []);
|
|
31
47
|
|
|
32
48
|
return (
|
|
33
49
|
<ThreadsContext.Provider
|
|
34
50
|
value={{
|
|
35
51
|
threadId,
|
|
36
52
|
setThreadId,
|
|
53
|
+
isThreadIdExplicit,
|
|
37
54
|
}}
|
|
38
55
|
>
|
|
39
56
|
{children}
|
|
@@ -106,6 +106,15 @@ export function CopilotChat({
|
|
|
106
106
|
() => providedThreadId ?? randomUUID(),
|
|
107
107
|
[providedThreadId],
|
|
108
108
|
);
|
|
109
|
+
// "Explicit" means a caller actually picked this thread — via the
|
|
110
|
+
// `threadId` prop on CopilotChat or a wrapping provider that marked its
|
|
111
|
+
// threadId as caller-chosen. An auto-minted UUID leaking down through a
|
|
112
|
+
// CopilotChatConfigurationProvider (e.g. from the v1 CopilotKit →
|
|
113
|
+
// ThreadsProvider chain) does NOT count; treating it as explicit is
|
|
114
|
+
// what made /connect fire against 404s and the welcome screen stay
|
|
115
|
+
// hidden for fresh empty chats.
|
|
116
|
+
const hasExplicitThreadId =
|
|
117
|
+
!!threadId || !!existingConfig?.hasExplicitThreadId;
|
|
109
118
|
|
|
110
119
|
const { agent } = useAgent({
|
|
111
120
|
agentId: resolvedAgentId,
|
|
@@ -205,14 +214,15 @@ export function CopilotChat({
|
|
|
205
214
|
string | null
|
|
206
215
|
>(null);
|
|
207
216
|
const isConnecting =
|
|
208
|
-
|
|
217
|
+
hasExplicitThreadId && lastConnectedThreadId !== resolvedThreadId;
|
|
209
218
|
|
|
210
219
|
useEffect(() => {
|
|
211
|
-
// When
|
|
212
|
-
// minted in this
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
220
|
+
// When the caller hasn't picked a specific thread, resolvedThreadId is a
|
|
221
|
+
// UUID minted locally (either in this CopilotChat or in a wrapping
|
|
222
|
+
// ThreadsProvider). The backend has never seen it, so /connect would
|
|
223
|
+
// always 404 — skip the call. A real thread is only created once the
|
|
224
|
+
// user runs the agent for the first time.
|
|
225
|
+
if (!hasExplicitThreadId) return;
|
|
216
226
|
|
|
217
227
|
let detached = false;
|
|
218
228
|
|
|
@@ -270,7 +280,7 @@ export function CopilotChat({
|
|
|
270
280
|
};
|
|
271
281
|
// copilotkit is intentionally excluded — it is a stable ref that never changes.
|
|
272
282
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
273
|
-
}, [resolvedThreadId, agent, resolvedAgentId,
|
|
283
|
+
}, [resolvedThreadId, agent, resolvedAgentId, hasExplicitThreadId]);
|
|
274
284
|
|
|
275
285
|
const onSubmitInput = useCallback(
|
|
276
286
|
async (value: string) => {
|
|
@@ -594,7 +604,7 @@ export function CopilotChat({
|
|
|
594
604
|
onDragLeave: handleDragLeave,
|
|
595
605
|
onDrop: handleDrop,
|
|
596
606
|
isConnecting,
|
|
597
|
-
hasExplicitThreadId
|
|
607
|
+
hasExplicitThreadId,
|
|
598
608
|
};
|
|
599
609
|
|
|
600
610
|
// Always create a provider with merged values
|
|
@@ -605,6 +615,7 @@ export function CopilotChat({
|
|
|
605
615
|
<CopilotChatConfigurationProvider
|
|
606
616
|
agentId={resolvedAgentId}
|
|
607
617
|
threadId={resolvedThreadId}
|
|
618
|
+
hasExplicitThreadId={hasExplicitThreadId}
|
|
608
619
|
labels={labels}
|
|
609
620
|
isModalDefaultOpen={isModalDefaultOpen}
|
|
610
621
|
>
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
4
|
+
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
|
|
5
|
+
import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
|
|
6
|
+
import { CopilotChat } from "../CopilotChat";
|
|
7
|
+
import { MockStepwiseAgent } from "../../../__tests__/utils/test-helpers";
|
|
8
|
+
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mock agent that records every connectAgent() invocation and resolves
|
|
12
|
+
* immediately with an empty run result. Tracking lives on the class so
|
|
13
|
+
* per-thread clones (from useAgent's WeakMap) share the counter.
|
|
14
|
+
*/
|
|
15
|
+
class TrackingAgent extends MockStepwiseAgent {
|
|
16
|
+
static connectCalls: Array<{
|
|
17
|
+
threadId: string | undefined;
|
|
18
|
+
agentId: string | undefined;
|
|
19
|
+
}> = [];
|
|
20
|
+
|
|
21
|
+
static reset() {
|
|
22
|
+
TrackingAgent.connectCalls = [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async connectAgent(
|
|
26
|
+
_params: unknown,
|
|
27
|
+
_subscriber: unknown,
|
|
28
|
+
): Promise<{ result: unknown; newMessages: [] }> {
|
|
29
|
+
TrackingAgent.connectCalls.push({
|
|
30
|
+
threadId: this.threadId,
|
|
31
|
+
agentId: this.agentId,
|
|
32
|
+
});
|
|
33
|
+
return { result: undefined, newMessages: [] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderWithKit(ui: React.ReactNode, agent: TrackingAgent) {
|
|
38
|
+
return render(
|
|
39
|
+
<CopilotKitProvider agents__unsafe_dev_only={{ [DEFAULT_AGENT_ID]: agent }}>
|
|
40
|
+
<div style={{ height: 400 }}>{ui}</div>
|
|
41
|
+
</CopilotKitProvider>,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Regression coverage for fix/welcome-not-showing-at-all.
|
|
47
|
+
*
|
|
48
|
+
* The underlying bug: the v1 <CopilotKit> wrapper pipes a ThreadsProvider-
|
|
49
|
+
* minted UUID through to CopilotChatConfigurationProvider as `threadId`.
|
|
50
|
+
* CopilotChat previously treated any non-empty providedThreadId as "caller
|
|
51
|
+
* supplied a real backend thread" and (a) fired /connect (→ 404 for an
|
|
52
|
+
* auto-minted UUID) and (b) suppressed the welcome screen forever. The
|
|
53
|
+
* fix threads an `hasExplicitThreadId` signal through the provider chain;
|
|
54
|
+
* these tests pin the contract that /connect and welcome-screen gating
|
|
55
|
+
* now follow that signal rather than `!!threadId`.
|
|
56
|
+
*/
|
|
57
|
+
describe("CopilotChat welcome / connect integration", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
TrackingAgent.reset();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("v1 bridge scenario (config provider marks threadId as non-explicit)", () => {
|
|
63
|
+
it("does not call connectAgent and shows the welcome screen", async () => {
|
|
64
|
+
const agent = new TrackingAgent();
|
|
65
|
+
agent.agentId = DEFAULT_AGENT_ID;
|
|
66
|
+
|
|
67
|
+
renderWithKit(
|
|
68
|
+
<CopilotChatConfigurationProvider
|
|
69
|
+
threadId="auto-minted-uuid"
|
|
70
|
+
hasExplicitThreadId={false}
|
|
71
|
+
>
|
|
72
|
+
<CopilotChat />
|
|
73
|
+
</CopilotChatConfigurationProvider>,
|
|
74
|
+
agent,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Give the connect-effect a chance to misfire.
|
|
78
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
79
|
+
|
|
80
|
+
expect(TrackingAgent.connectCalls).toHaveLength(0);
|
|
81
|
+
expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("plain CopilotChat (no threadId anywhere)", () => {
|
|
86
|
+
it("does not call connectAgent and shows the welcome screen", async () => {
|
|
87
|
+
const agent = new TrackingAgent();
|
|
88
|
+
agent.agentId = DEFAULT_AGENT_ID;
|
|
89
|
+
|
|
90
|
+
renderWithKit(<CopilotChat />, agent);
|
|
91
|
+
|
|
92
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
93
|
+
|
|
94
|
+
expect(TrackingAgent.connectCalls).toHaveLength(0);
|
|
95
|
+
expect(screen.getByTestId("copilot-welcome-screen")).toBeDefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("explicit threadId via CopilotChat prop", () => {
|
|
100
|
+
it("calls connectAgent with that threadId and suppresses the welcome screen", async () => {
|
|
101
|
+
const agent = new TrackingAgent();
|
|
102
|
+
agent.agentId = DEFAULT_AGENT_ID;
|
|
103
|
+
|
|
104
|
+
renderWithKit(<CopilotChat threadId="real-thread" />, agent);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(TrackingAgent.connectCalls.length).toBeGreaterThan(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The per-thread clone carries threadId; agentId is the default.
|
|
111
|
+
expect(
|
|
112
|
+
TrackingAgent.connectCalls.some((c) => c.threadId === "real-thread"),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
|
|
115
|
+
// Welcome screen is suppressed even after connect resolves, because the
|
|
116
|
+
// thread was caller-picked (hasExplicitThreadId=true).
|
|
117
|
+
expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("explicit threadId via wrapping CopilotChatConfigurationProvider", () => {
|
|
122
|
+
it("inherits explicitness from the provider and connects", async () => {
|
|
123
|
+
const agent = new TrackingAgent();
|
|
124
|
+
agent.agentId = DEFAULT_AGENT_ID;
|
|
125
|
+
|
|
126
|
+
renderWithKit(
|
|
127
|
+
<CopilotChatConfigurationProvider threadId="from-config">
|
|
128
|
+
<CopilotChat />
|
|
129
|
+
</CopilotChatConfigurationProvider>,
|
|
130
|
+
agent,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(TrackingAgent.connectCalls.length).toBeGreaterThan(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(
|
|
138
|
+
TrackingAgent.connectCalls.some((c) => c.threadId === "from-config"),
|
|
139
|
+
).toBe(true);
|
|
140
|
+
expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("thread switch between two explicit threads", () => {
|
|
145
|
+
it("keeps the welcome screen hidden across the switch", async () => {
|
|
146
|
+
const agent = new TrackingAgent();
|
|
147
|
+
agent.agentId = DEFAULT_AGENT_ID;
|
|
148
|
+
|
|
149
|
+
const { rerender } = renderWithKit(
|
|
150
|
+
<CopilotChat threadId="thread-a" />,
|
|
151
|
+
agent,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
expect(
|
|
156
|
+
TrackingAgent.connectCalls.some((c) => c.threadId === "thread-a"),
|
|
157
|
+
).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
// After thread-a's connect resolves, welcome must still be hidden
|
|
160
|
+
// because the thread is caller-picked.
|
|
161
|
+
expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
|
|
162
|
+
|
|
163
|
+
rerender(
|
|
164
|
+
<CopilotKitProvider
|
|
165
|
+
agents__unsafe_dev_only={{ [DEFAULT_AGENT_ID]: agent }}
|
|
166
|
+
>
|
|
167
|
+
<div style={{ height: 400 }}>
|
|
168
|
+
<CopilotChat threadId="thread-b" />
|
|
169
|
+
</div>
|
|
170
|
+
</CopilotKitProvider>,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// During the switch (lastConnected="thread-a" !== "thread-b") isConnecting
|
|
174
|
+
// is true — welcome must not flash.
|
|
175
|
+
expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
|
|
176
|
+
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(
|
|
179
|
+
TrackingAgent.connectCalls.some((c) => c.threadId === "thread-b"),
|
|
180
|
+
).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
// And after thread-b's connect resolves.
|
|
183
|
+
expect(screen.queryByTestId("copilot-welcome-screen")).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -45,6 +45,11 @@ export interface CopilotChatConfigurationValue {
|
|
|
45
45
|
threadId: string;
|
|
46
46
|
isModalOpen: boolean;
|
|
47
47
|
setModalOpen: (open: boolean) => void;
|
|
48
|
+
// True when the current threadId was chosen by the caller rather than
|
|
49
|
+
// silently minted inside the provider chain. Consumers that only make
|
|
50
|
+
// sense against a real backend thread (e.g. /connect, suppressing the
|
|
51
|
+
// welcome screen on switch) gate on this instead of `!!threadId`.
|
|
52
|
+
hasExplicitThreadId: boolean;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
// Create the configuration context
|
|
@@ -57,13 +62,26 @@ export interface CopilotChatConfigurationProviderProps {
|
|
|
57
62
|
labels?: Partial<CopilotChatLabels>;
|
|
58
63
|
agentId?: string;
|
|
59
64
|
threadId?: string;
|
|
65
|
+
// Lets internal wrappers (e.g. the v1 CopilotKit bridge, which pipes a
|
|
66
|
+
// ThreadsProvider-minted UUID through as `threadId`) declare that the
|
|
67
|
+
// threadId they are supplying is NOT a caller choice. When omitted, the
|
|
68
|
+
// provider infers explicitness from whether the `threadId` prop itself
|
|
69
|
+
// was supplied.
|
|
70
|
+
hasExplicitThreadId?: boolean;
|
|
60
71
|
isModalDefaultOpen?: boolean;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
// Provider component
|
|
64
75
|
export const CopilotChatConfigurationProvider: React.FC<
|
|
65
76
|
CopilotChatConfigurationProviderProps
|
|
66
|
-
> = ({
|
|
77
|
+
> = ({
|
|
78
|
+
children,
|
|
79
|
+
labels,
|
|
80
|
+
agentId,
|
|
81
|
+
threadId,
|
|
82
|
+
hasExplicitThreadId,
|
|
83
|
+
isModalDefaultOpen,
|
|
84
|
+
}) => {
|
|
67
85
|
const parentConfig = useContext(CopilotChatConfiguration);
|
|
68
86
|
|
|
69
87
|
// Stabilize labels references so that inline objects (new reference on every
|
|
@@ -92,6 +110,14 @@ export const CopilotChatConfigurationProvider: React.FC<
|
|
|
92
110
|
return randomUUID();
|
|
93
111
|
}, [threadId, parentConfig?.threadId]);
|
|
94
112
|
|
|
113
|
+
// If a caller passed `hasExplicitThreadId`, trust it verbatim (lets the v1
|
|
114
|
+
// bridge mark an auto-minted UUID as non-explicit). Otherwise infer: a
|
|
115
|
+
// threadId supplied as a prop here is by definition a caller choice.
|
|
116
|
+
const ownHasExplicitThreadId =
|
|
117
|
+
hasExplicitThreadId !== undefined ? hasExplicitThreadId : !!threadId;
|
|
118
|
+
const resolvedHasExplicitThreadId =
|
|
119
|
+
ownHasExplicitThreadId || !!parentConfig?.hasExplicitThreadId;
|
|
120
|
+
|
|
95
121
|
const resolvedDefaultOpen = isModalDefaultOpen ?? true;
|
|
96
122
|
|
|
97
123
|
const [internalModalOpen, setInternalModalOpen] =
|
|
@@ -141,6 +167,7 @@ export const CopilotChatConfigurationProvider: React.FC<
|
|
|
141
167
|
labels: mergedLabels,
|
|
142
168
|
agentId: resolvedAgentId,
|
|
143
169
|
threadId: resolvedThreadId,
|
|
170
|
+
hasExplicitThreadId: resolvedHasExplicitThreadId,
|
|
144
171
|
isModalOpen: resolvedIsModalOpen,
|
|
145
172
|
setModalOpen: resolvedSetModalOpen,
|
|
146
173
|
}),
|
|
@@ -148,6 +175,7 @@ export const CopilotChatConfigurationProvider: React.FC<
|
|
|
148
175
|
mergedLabels,
|
|
149
176
|
resolvedAgentId,
|
|
150
177
|
resolvedThreadId,
|
|
178
|
+
resolvedHasExplicitThreadId,
|
|
151
179
|
resolvedIsModalOpen,
|
|
152
180
|
resolvedSetModalOpen,
|
|
153
181
|
],
|