@copilotkit/vue 1.72.1-canary.sp-longthread-perf → 1.73.1
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/PARITY.md +15 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +42 -41
- package/dist/index.mjs.map +1 -1
- package/dist/use-render-activity-message-6LwUTCNm.cjs +85 -0
- package/dist/use-render-activity-message-6LwUTCNm.cjs.map +1 -0
- package/dist/{use-render-activity-message-Ciee03Rx.js → use-render-activity-message-Di_L8HCv.js} +4501 -4400
- package/dist/use-render-activity-message-Di_L8HCv.js.map +1 -0
- package/dist/v2/components/LicenseWarningBanner.vue.d.ts +3 -5
- package/dist/v2/components/LicenseWarningBanner.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotChat.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotChatAssistantMessage.vue.d.ts +4 -0
- package/dist/v2/components/chat/CopilotChatAssistantMessage.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotChatInput.vue.d.ts +7 -0
- package/dist/v2/components/chat/CopilotChatInput.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotChatMessageView.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotChatUserMessage.vue.d.ts +2 -0
- package/dist/v2/components/chat/CopilotChatUserMessage.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotChatView.vue.d.ts +8 -2
- package/dist/v2/components/chat/CopilotChatView.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotPopup.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotPopupView.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotPopupViewInternal.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotPopupWelcomeScreen.vue.d.ts +7 -4
- package/dist/v2/components/chat/CopilotPopupWelcomeScreen.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotSidebar.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotSidebarView.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotSidebarViewInternal.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotSidebarWelcomeScreen.vue.d.ts +8 -5
- package/dist/v2/components/chat/CopilotSidebarWelcomeScreen.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/index.d.ts +50 -26
- package/dist/v2/components/chat/index.d.ts.map +1 -1
- package/dist/v2/components/chat/types.d.ts +44 -17
- package/dist/v2/components/chat/types.d.ts.map +1 -1
- package/dist/v2/hooks/use-human-in-the-loop.d.ts.map +1 -1
- package/dist/v2/index.cjs +1 -1
- package/dist/v2/index.mjs +1 -1
- package/dist/v2/providers/CopilotKitProvider.types.d.ts +25 -1
- package/dist/v2/providers/CopilotKitProvider.types.d.ts.map +1 -1
- package/dist/v2/providers/CopilotKitProvider.vue.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/v2/components/LicenseWarningBanner.vue +5 -17
- package/src/v2/components/__tests__/LicenseWarningBanner.test.ts +23 -0
- package/src/v2/components/chat/CopilotChat.vue +23 -22
- package/src/v2/components/chat/CopilotChatAssistantMessage.vue +12 -21
- package/src/v2/components/chat/CopilotChatInput.vue +32 -26
- package/src/v2/components/chat/CopilotChatMessageView.vue +29 -3
- package/src/v2/components/chat/CopilotChatUserMessage.vue +19 -19
- package/src/v2/components/chat/CopilotChatView.vue +31 -39
- package/src/v2/components/chat/CopilotPopup.vue +8 -10
- package/src/v2/components/chat/CopilotPopupView.vue +8 -18
- package/src/v2/components/chat/CopilotPopupViewInternal.vue +5 -19
- package/src/v2/components/chat/CopilotPopupWelcomeScreen.vue +36 -6
- package/src/v2/components/chat/CopilotSidebar.vue +8 -10
- package/src/v2/components/chat/CopilotSidebarView.vue +8 -18
- package/src/v2/components/chat/CopilotSidebarViewInternal.vue +5 -19
- package/src/v2/components/chat/CopilotSidebarWelcomeScreen.vue +36 -6
- package/src/v2/components/chat/__tests__/CopilotChat.e2e.test.ts +156 -5
- package/src/v2/components/chat/__tests__/CopilotChatInput.test.ts +34 -0
- package/src/v2/components/chat/__tests__/CopilotChatMessageView.rowKeys.test.ts +140 -0
- package/src/v2/components/chat/__tests__/CopilotChatUserMessage.test.ts +46 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.test.ts +118 -0
- package/src/v2/components/chat/__tests__/CopilotPopupView.test.ts +79 -0
- package/src/v2/components/chat/__tests__/CopilotSidebarView.test.ts +93 -0
- package/src/v2/components/chat/types.ts +73 -18
- package/src/v2/hooks/__tests__/use-human-in-the-loop.e2e.test.ts +32 -6
- package/src/v2/hooks/use-human-in-the-loop.ts +1 -0
- package/src/v2/providers/CopilotKitProvider.types.ts +28 -1
- package/src/v2/providers/CopilotKitProvider.vue +86 -6
- package/src/v2/providers/__tests__/CopilotKitProvider.hitl.e2e.test.ts +192 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.messageFilter.test.ts +159 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.test.ts +275 -21
- package/src/v2/providers/__tests__/CopilotKitProvider.wildcard.test.ts +19 -1
- package/dist/use-render-activity-message-Ciee03Rx.js.map +0 -1
- package/dist/use-render-activity-message-DXJDZGkW.cjs +0 -85
- package/dist/use-render-activity-message-DXJDZGkW.cjs.map +0 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end coverage for the `humanInTheLoop` **prop** path (OSS-803).
|
|
3
|
+
*
|
|
4
|
+
* The unit tests in CopilotKitProvider.test.ts drive the registered renderer
|
|
5
|
+
* directly. This file mounts the real pipeline instead — CopilotChat →
|
|
6
|
+
* CopilotChatToolCallsView → the provider's wrapper — so the wrapper is
|
|
7
|
+
* exercised as a Vue functional component and the pending-interaction key comes
|
|
8
|
+
* from core's real tool call id rather than a synthesized handler context.
|
|
9
|
+
*/
|
|
10
|
+
import { defineComponent } from "vue";
|
|
11
|
+
import type { PropType } from "vue";
|
|
12
|
+
import { screen, fireEvent, waitFor, cleanup } from "@testing-library/vue";
|
|
13
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { ToolCallStatus } from "@copilotkit/core";
|
|
16
|
+
import CopilotChat from "../../components/chat/CopilotChat.vue";
|
|
17
|
+
import {
|
|
18
|
+
MockStepwiseAgent,
|
|
19
|
+
renderWithCopilotKit,
|
|
20
|
+
runStartedEvent,
|
|
21
|
+
runFinishedEvent,
|
|
22
|
+
toolCallChunkEvent,
|
|
23
|
+
testId,
|
|
24
|
+
} from "../../__tests__/utils/test-helpers";
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
cleanup();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const HITLRenderer = defineComponent({
|
|
31
|
+
props: {
|
|
32
|
+
name: { type: String, required: true },
|
|
33
|
+
description: { type: String, required: true },
|
|
34
|
+
status: { type: String as PropType<ToolCallStatus>, required: true },
|
|
35
|
+
args: {
|
|
36
|
+
type: Object as PropType<{ action?: string }>,
|
|
37
|
+
required: true,
|
|
38
|
+
},
|
|
39
|
+
result: { type: String, required: false },
|
|
40
|
+
respond: {
|
|
41
|
+
type: Function as PropType<
|
|
42
|
+
((result: unknown) => Promise<void>) | undefined
|
|
43
|
+
>,
|
|
44
|
+
required: false,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
template: `
|
|
48
|
+
<div data-testid="hitl-tool">
|
|
49
|
+
<div data-testid="hitl-name">{{ name }}</div>
|
|
50
|
+
<div data-testid="hitl-description">{{ description }}</div>
|
|
51
|
+
<div data-testid="hitl-status">{{ status }}</div>
|
|
52
|
+
<div data-testid="hitl-action">{{ args.action ?? "" }}</div>
|
|
53
|
+
<button
|
|
54
|
+
v-if="respond"
|
|
55
|
+
data-testid="hitl-approve"
|
|
56
|
+
@click="respond(JSON.stringify({ approved: true }))"
|
|
57
|
+
>
|
|
58
|
+
Approve
|
|
59
|
+
</button>
|
|
60
|
+
<div v-if="result" data-testid="hitl-result">{{ result }}</div>
|
|
61
|
+
</div>
|
|
62
|
+
`,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const ChatHost = defineComponent({
|
|
66
|
+
components: { CopilotChat },
|
|
67
|
+
template: `
|
|
68
|
+
<div style="height: 400px;">
|
|
69
|
+
<CopilotChat :welcome-screen="false" />
|
|
70
|
+
</div>
|
|
71
|
+
`,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
async function submitMessage(value: string) {
|
|
75
|
+
const input = await screen.findByRole("textbox");
|
|
76
|
+
await fireEvent.update(input, value);
|
|
77
|
+
await fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(screen.getByText(value)).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("CopilotKitProvider humanInTheLoop prop E2E", () => {
|
|
84
|
+
it("waits for the user and completes with the response", async () => {
|
|
85
|
+
const agent = new MockStepwiseAgent();
|
|
86
|
+
|
|
87
|
+
renderWithCopilotKit({
|
|
88
|
+
agent,
|
|
89
|
+
humanInTheLoop: [
|
|
90
|
+
{
|
|
91
|
+
name: "approvalTool",
|
|
92
|
+
description: "Requires human approval",
|
|
93
|
+
parameters: z.object({ action: z.string() }),
|
|
94
|
+
render: HITLRenderer,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
children: ChatHost,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await submitMessage("Request approval");
|
|
101
|
+
|
|
102
|
+
await agent.emit(runStartedEvent());
|
|
103
|
+
await agent.emit(
|
|
104
|
+
toolCallChunkEvent({
|
|
105
|
+
toolCallId: testId("tc"),
|
|
106
|
+
toolCallName: "approvalTool",
|
|
107
|
+
parentMessageId: testId("msg"),
|
|
108
|
+
delta: JSON.stringify({ action: "delete" }),
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
expect(screen.getByTestId("hitl-status").textContent).toBe(
|
|
114
|
+
ToolCallStatus.InProgress,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
expect(screen.getByTestId("hitl-name").textContent).toBe("approvalTool");
|
|
118
|
+
expect(screen.getByTestId("hitl-description").textContent).toBe(
|
|
119
|
+
"Requires human approval",
|
|
120
|
+
);
|
|
121
|
+
expect(screen.getByTestId("hitl-action").textContent).toBe("delete");
|
|
122
|
+
|
|
123
|
+
await agent.emit(runFinishedEvent());
|
|
124
|
+
await agent.complete();
|
|
125
|
+
|
|
126
|
+
// The run has finished, so the tool handler is now executing. Before the
|
|
127
|
+
// fix the handler had already resolved undefined, so the card never reached
|
|
128
|
+
// Executing and no approve button was ever rendered.
|
|
129
|
+
const approveButton = await screen.findByTestId("hitl-approve");
|
|
130
|
+
expect(screen.getByTestId("hitl-status").textContent).toBe(
|
|
131
|
+
ToolCallStatus.Executing,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
await fireEvent.click(approveButton);
|
|
135
|
+
|
|
136
|
+
await waitFor(() => {
|
|
137
|
+
expect(screen.getByTestId("hitl-status").textContent).toBe(
|
|
138
|
+
ToolCallStatus.Complete,
|
|
139
|
+
);
|
|
140
|
+
expect(screen.getByTestId("hitl-result").textContent).toContain(
|
|
141
|
+
"approved",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("passes the invoked tool name to a wildcard registration", async () => {
|
|
147
|
+
const agent = new MockStepwiseAgent();
|
|
148
|
+
|
|
149
|
+
renderWithCopilotKit({
|
|
150
|
+
agent,
|
|
151
|
+
humanInTheLoop: [
|
|
152
|
+
{
|
|
153
|
+
name: "*",
|
|
154
|
+
description: "Approve any tool",
|
|
155
|
+
parameters: z.object({ action: z.string() }),
|
|
156
|
+
render: HITLRenderer,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
children: ChatHost,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await submitMessage("Delete the file");
|
|
163
|
+
|
|
164
|
+
await agent.emit(runStartedEvent());
|
|
165
|
+
await agent.emit(
|
|
166
|
+
toolCallChunkEvent({
|
|
167
|
+
toolCallId: testId("tc"),
|
|
168
|
+
toolCallName: "deleteFile",
|
|
169
|
+
parentMessageId: testId("msg"),
|
|
170
|
+
delta: JSON.stringify({ action: "delete" }),
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await waitFor(() => {
|
|
175
|
+
expect(screen.getByTestId("hitl-name").textContent).toBe("deleteFile");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await agent.emit(runFinishedEvent());
|
|
179
|
+
await agent.complete();
|
|
180
|
+
|
|
181
|
+
const approveButton = await screen.findByTestId("hitl-approve");
|
|
182
|
+
expect(screen.getByTestId("hitl-name").textContent).toBe("deleteFile");
|
|
183
|
+
|
|
184
|
+
await fireEvent.click(approveButton);
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(screen.getByTestId("hitl-status").textContent).toBe(
|
|
188
|
+
ToolCallStatus.Complete,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { cleanup, render } from "@testing-library/vue";
|
|
2
|
+
import { defineComponent, nextTick } from "vue";
|
|
3
|
+
import type { Component, PropType } from "vue";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { CopilotKitMessageFilter } from "@copilotkit/core";
|
|
6
|
+
import CopilotKitProvider from "../CopilotKitProvider.vue";
|
|
7
|
+
import { useCopilotKit } from "../useCopilotKit";
|
|
8
|
+
import type * as VueCoreModule from "../../lib/vue-core";
|
|
9
|
+
import type { CopilotKitCoreVue } from "../../lib/vue-core";
|
|
10
|
+
|
|
11
|
+
/** Keeps the final turn only — the shape #1482 asks for. */
|
|
12
|
+
const keepLastTurn: CopilotKitMessageFilter = (messages) => messages.slice(-1);
|
|
13
|
+
/** A second, distinguishable filter, for asserting a swap took effect. */
|
|
14
|
+
const keepLastTwo: CopilotKitMessageFilter = (messages) => messages.slice(-2);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every config the provider constructed a core with, in order. The real class
|
|
18
|
+
* still does the work — only the constructor argument is recorded — so these
|
|
19
|
+
* tests read the same core the app would get.
|
|
20
|
+
*/
|
|
21
|
+
const ctorConfigs: { messageFilter?: CopilotKitMessageFilter }[] = [];
|
|
22
|
+
|
|
23
|
+
vi.mock("../../lib/vue-core", async (importOriginal) => {
|
|
24
|
+
const actual = await importOriginal<typeof VueCoreModule>();
|
|
25
|
+
class RecordingCore extends actual.CopilotKitCoreVue {
|
|
26
|
+
constructor(
|
|
27
|
+
config: ConstructorParameters<typeof actual.CopilotKitCoreVue>[0],
|
|
28
|
+
) {
|
|
29
|
+
ctorConfigs.push(config as { messageFilter?: CopilotKitMessageFilter });
|
|
30
|
+
super(config);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { ...actual, CopilotKitCoreVue: RecordingCore };
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parity with React's `messageFilter` prop (#1482). The filter itself is
|
|
38
|
+
* covered by `packages/core/src/__tests__/message-filter.test.ts`; what is
|
|
39
|
+
* framework-specific, and what these assert, is that the prop reaches the core
|
|
40
|
+
* both on mount and on a later change — including a change back to
|
|
41
|
+
* `undefined`, which is how an app turns trimming off.
|
|
42
|
+
*/
|
|
43
|
+
function renderProvider(args: {
|
|
44
|
+
child: Component;
|
|
45
|
+
messageFilter?: CopilotKitMessageFilter;
|
|
46
|
+
}) {
|
|
47
|
+
const Host = defineComponent({
|
|
48
|
+
components: {
|
|
49
|
+
CopilotKitProvider,
|
|
50
|
+
ChildComponent: args.child,
|
|
51
|
+
},
|
|
52
|
+
props: {
|
|
53
|
+
messageFilter: {
|
|
54
|
+
type: Function as PropType<CopilotKitMessageFilter | undefined>,
|
|
55
|
+
required: false,
|
|
56
|
+
default: undefined,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
template: `
|
|
60
|
+
<CopilotKitProvider
|
|
61
|
+
runtime-url="/api/copilotkit"
|
|
62
|
+
:message-filter="messageFilter"
|
|
63
|
+
>
|
|
64
|
+
<ChildComponent />
|
|
65
|
+
</CopilotKitProvider>
|
|
66
|
+
`,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return render(Host, {
|
|
70
|
+
props: { messageFilter: args.messageFilter },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createCoreCollector(): {
|
|
75
|
+
child: Component;
|
|
76
|
+
getCore: () => CopilotKitCoreVue;
|
|
77
|
+
} {
|
|
78
|
+
const instances: CopilotKitCoreVue[] = [];
|
|
79
|
+
const Collector = defineComponent({
|
|
80
|
+
setup() {
|
|
81
|
+
const { copilotkit } = useCopilotKit();
|
|
82
|
+
instances.push(copilotkit.value);
|
|
83
|
+
return () => null;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
child: Collector,
|
|
88
|
+
getCore: () => {
|
|
89
|
+
if (instances.length === 0) {
|
|
90
|
+
throw new Error("CopilotKit core not captured yet");
|
|
91
|
+
}
|
|
92
|
+
return instances[instances.length - 1]!;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe("CopilotKitProvider messageFilter", () => {
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
ctorConfigs.length = 0;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
cleanup();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("hands the filter to the core at construction, not only on mount", async () => {
|
|
107
|
+
const { child } = createCoreCollector();
|
|
108
|
+
renderProvider({ child, messageFilter: keepLastTurn });
|
|
109
|
+
await nextTick();
|
|
110
|
+
|
|
111
|
+
// The core opens its `/info` request from the constructor, and an agent
|
|
112
|
+
// that response advertises is built with whatever filter the core holds
|
|
113
|
+
// at that moment. A filter applied only from the mount effect would let
|
|
114
|
+
// that first agent make its first run with the whole thread.
|
|
115
|
+
expect(ctorConfigs.length).toBeGreaterThanOrEqual(1);
|
|
116
|
+
expect(ctorConfigs[0]?.messageFilter).toBe(keepLastTurn);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("threads the filter to the core on mount", async () => {
|
|
120
|
+
const { child, getCore } = createCoreCollector();
|
|
121
|
+
renderProvider({ child, messageFilter: keepLastTurn });
|
|
122
|
+
await nextTick();
|
|
123
|
+
|
|
124
|
+
expect(getCore().messageFilter).toBe(keepLastTurn);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("leaves the core unfiltered when the prop is absent", async () => {
|
|
128
|
+
const { child, getCore } = createCoreCollector();
|
|
129
|
+
|
|
130
|
+
renderProvider({ child });
|
|
131
|
+
await nextTick();
|
|
132
|
+
|
|
133
|
+
expect(getCore().messageFilter).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("adopts a filter the app swaps in after mount", async () => {
|
|
137
|
+
const { child, getCore } = createCoreCollector();
|
|
138
|
+
const view = renderProvider({ child, messageFilter: keepLastTurn });
|
|
139
|
+
await nextTick();
|
|
140
|
+
expect(getCore().messageFilter).toBe(keepLastTurn);
|
|
141
|
+
|
|
142
|
+
await view.rerender({ messageFilter: keepLastTwo });
|
|
143
|
+
await nextTick();
|
|
144
|
+
|
|
145
|
+
expect(getCore().messageFilter).toBe(keepLastTwo);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("clears the filter when the prop goes back to undefined", async () => {
|
|
149
|
+
const { child, getCore } = createCoreCollector();
|
|
150
|
+
const view = renderProvider({ child, messageFilter: keepLastTurn });
|
|
151
|
+
await nextTick();
|
|
152
|
+
expect(getCore().messageFilter).toBe(keepLastTurn);
|
|
153
|
+
|
|
154
|
+
await view.rerender({ messageFilter: undefined });
|
|
155
|
+
await nextTick();
|
|
156
|
+
|
|
157
|
+
expect(getCore().messageFilter).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -9,12 +9,16 @@ import {
|
|
|
9
9
|
ref,
|
|
10
10
|
watchEffect,
|
|
11
11
|
} from "vue";
|
|
12
|
+
import type { VNode } from "vue";
|
|
12
13
|
import { renderToString } from "vue/server-renderer";
|
|
13
14
|
import type {
|
|
14
15
|
CopilotKitCoreSubscriber,
|
|
15
16
|
FrontendToolHandlerContext,
|
|
16
17
|
} from "@copilotkit/core";
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
CopilotKitCoreRuntimeConnectionStatus,
|
|
20
|
+
ToolCallStatus,
|
|
21
|
+
} from "@copilotkit/core";
|
|
18
22
|
import { defineWebInspector } from "@copilotkit/web-inspector";
|
|
19
23
|
import { z } from "zod";
|
|
20
24
|
import CopilotKitProvider from "../CopilotKitProvider.vue";
|
|
@@ -338,12 +342,45 @@ describe("CopilotKitProvider", () => {
|
|
|
338
342
|
});
|
|
339
343
|
|
|
340
344
|
describe("humanInTheLoop prop", () => {
|
|
345
|
+
const HitlComponent = defineComponent({
|
|
346
|
+
setup() {
|
|
347
|
+
return () => h("div", "Test");
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/** A handler context shaped like the one core passes to frontend tools. */
|
|
352
|
+
const hitlContext = (toolCallId: string, signal?: AbortSignal) =>
|
|
353
|
+
({
|
|
354
|
+
toolCall: { id: toolCallId },
|
|
355
|
+
signal,
|
|
356
|
+
}) as unknown as FrontendToolHandlerContext;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Invokes the provider's registered renderer the way `useRenderToolCall`
|
|
360
|
+
* does and returns the props it forwarded to the user's component. The
|
|
361
|
+
* wrapper builds its VNode with `h()`, so the injected props are readable
|
|
362
|
+
* without mounting.
|
|
363
|
+
*/
|
|
364
|
+
const renderHitl = (
|
|
365
|
+
getCore: () => CopilotKitCoreContextValue,
|
|
366
|
+
registeredName: string,
|
|
367
|
+
props: Record<string, unknown>,
|
|
368
|
+
) => {
|
|
369
|
+
const render = getCore().renderToolCalls.find(
|
|
370
|
+
(rc) => rc.name === registeredName,
|
|
371
|
+
)?.render as (p: Record<string, unknown>) => VNode;
|
|
372
|
+
return render(props).props as {
|
|
373
|
+
name?: string;
|
|
374
|
+
description?: string;
|
|
375
|
+
agentId?: string;
|
|
376
|
+
respond?: (result: unknown) => Promise<void>;
|
|
377
|
+
};
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
381
|
+
|
|
341
382
|
it("processes humanInTheLoop tools and creates handlers", () => {
|
|
342
|
-
const TestComponent =
|
|
343
|
-
setup() {
|
|
344
|
-
return () => h("div", "Test");
|
|
345
|
-
},
|
|
346
|
-
});
|
|
383
|
+
const TestComponent = HitlComponent;
|
|
347
384
|
const humanInTheLoop: VueHumanInTheLoop[] = [
|
|
348
385
|
{
|
|
349
386
|
name: "approvalTool",
|
|
@@ -358,25 +395,101 @@ describe("CopilotKitProvider", () => {
|
|
|
358
395
|
expect(tool).toBeDefined();
|
|
359
396
|
expect(tool?.handler).toBeDefined();
|
|
360
397
|
|
|
398
|
+
// The registered renderer wraps the user's component so `respond` and the
|
|
399
|
+
// tool's description can be injected per status.
|
|
361
400
|
const renderTool = getCore().renderToolCalls.find(
|
|
362
401
|
(rc) => rc.name === "approvalTool",
|
|
363
402
|
);
|
|
364
403
|
expect(renderTool).toBeDefined();
|
|
365
|
-
expect(renderTool?.render).
|
|
404
|
+
expect(typeof renderTool?.render).toBe("function");
|
|
366
405
|
});
|
|
367
406
|
|
|
368
|
-
it("
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
407
|
+
it("keeps the tool call pending until respond is called", async () => {
|
|
408
|
+
// Reproduces OSS-803: the handler used to warn and resolve undefined
|
|
409
|
+
// immediately, so the HITL UI flashed and disappeared instead of waiting
|
|
410
|
+
// for the user.
|
|
411
|
+
const humanInTheLoop: VueHumanInTheLoop[] = [
|
|
412
|
+
{
|
|
413
|
+
name: "interactiveTool",
|
|
414
|
+
description: "Interactive tool",
|
|
415
|
+
parameters: z.object({ data: z.string() }),
|
|
416
|
+
render: HitlComponent,
|
|
417
|
+
},
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
const { getCore } = mountWithProvider(() => h("div"), { humanInTheLoop });
|
|
421
|
+
const handler = getCore().getTool({
|
|
422
|
+
toolName: "interactiveTool",
|
|
423
|
+
})?.handler;
|
|
424
|
+
expect(handler).toBeDefined();
|
|
425
|
+
|
|
426
|
+
let settled = false;
|
|
427
|
+
const pending = handler!({ data: "test" }, hitlContext("tc-1")).then(
|
|
428
|
+
(result) => {
|
|
429
|
+
settled = true;
|
|
430
|
+
return result;
|
|
372
431
|
},
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
await flush();
|
|
435
|
+
expect(settled).toBe(false);
|
|
436
|
+
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
|
|
437
|
+
expect.stringContaining("no interactive handler is set up"),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const props = renderHitl(getCore, "interactiveTool", {
|
|
441
|
+
name: "interactiveTool",
|
|
442
|
+
toolCallId: "tc-1",
|
|
443
|
+
args: { data: "test" },
|
|
444
|
+
status: ToolCallStatus.Executing,
|
|
445
|
+
result: undefined,
|
|
373
446
|
});
|
|
447
|
+
|
|
448
|
+
expect(props.respond).toBeDefined();
|
|
449
|
+
await props.respond!("approved");
|
|
450
|
+
|
|
451
|
+
await expect(pending).resolves.toBe("approved");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("exposes respond only while the tool is executing", () => {
|
|
374
455
|
const humanInTheLoop: VueHumanInTheLoop[] = [
|
|
375
456
|
{
|
|
376
457
|
name: "interactiveTool",
|
|
377
458
|
description: "Interactive tool",
|
|
378
459
|
parameters: z.object({ data: z.string() }),
|
|
379
|
-
render:
|
|
460
|
+
render: HitlComponent,
|
|
461
|
+
},
|
|
462
|
+
];
|
|
463
|
+
|
|
464
|
+
const { getCore } = mountWithProvider(() => h("div"), { humanInTheLoop });
|
|
465
|
+
|
|
466
|
+
const inProgress = renderHitl(getCore, "interactiveTool", {
|
|
467
|
+
name: "interactiveTool",
|
|
468
|
+
toolCallId: "tc-1",
|
|
469
|
+
args: { data: "test" },
|
|
470
|
+
status: ToolCallStatus.InProgress,
|
|
471
|
+
result: undefined,
|
|
472
|
+
});
|
|
473
|
+
expect(inProgress.respond).toBeUndefined();
|
|
474
|
+
expect(inProgress.description).toBe("Interactive tool");
|
|
475
|
+
|
|
476
|
+
const complete = renderHitl(getCore, "interactiveTool", {
|
|
477
|
+
name: "interactiveTool",
|
|
478
|
+
toolCallId: "tc-1",
|
|
479
|
+
args: { data: "test" },
|
|
480
|
+
status: ToolCallStatus.Complete,
|
|
481
|
+
result: "approved",
|
|
482
|
+
});
|
|
483
|
+
expect(complete.respond).toBeUndefined();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("rejects the pending tool call when the run is aborted", async () => {
|
|
487
|
+
const humanInTheLoop: VueHumanInTheLoop[] = [
|
|
488
|
+
{
|
|
489
|
+
name: "interactiveTool",
|
|
490
|
+
description: "Interactive tool",
|
|
491
|
+
parameters: z.object({ data: z.string() }),
|
|
492
|
+
render: HitlComponent,
|
|
380
493
|
},
|
|
381
494
|
];
|
|
382
495
|
|
|
@@ -384,19 +497,147 @@ describe("CopilotKitProvider", () => {
|
|
|
384
497
|
const handler = getCore().getTool({
|
|
385
498
|
toolName: "interactiveTool",
|
|
386
499
|
})?.handler;
|
|
387
|
-
expect(handler).toBeDefined();
|
|
388
500
|
|
|
389
|
-
const
|
|
501
|
+
const controller = new AbortController();
|
|
502
|
+
const pending = handler!(
|
|
390
503
|
{ data: "test" },
|
|
391
|
-
|
|
504
|
+
hitlContext("tc-1", controller.signal),
|
|
392
505
|
);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
506
|
+
|
|
507
|
+
controller.abort();
|
|
508
|
+
|
|
509
|
+
// An explicit rejection makes core record an error tool result rather
|
|
510
|
+
// than silently resolving empty (#5554).
|
|
511
|
+
await expect(pending).rejects.toThrow(
|
|
512
|
+
"Human-in-the-loop interaction aborted",
|
|
398
513
|
);
|
|
399
514
|
});
|
|
515
|
+
|
|
516
|
+
it("rejects immediately when the run was already aborted", async () => {
|
|
517
|
+
const humanInTheLoop: VueHumanInTheLoop[] = [
|
|
518
|
+
{
|
|
519
|
+
name: "interactiveTool",
|
|
520
|
+
description: "Interactive tool",
|
|
521
|
+
parameters: z.object({ data: z.string() }),
|
|
522
|
+
render: HitlComponent,
|
|
523
|
+
},
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
const { getCore } = mountWithProvider(() => h("div"), { humanInTheLoop });
|
|
527
|
+
const handler = getCore().getTool({
|
|
528
|
+
toolName: "interactiveTool",
|
|
529
|
+
})?.handler;
|
|
530
|
+
|
|
531
|
+
const controller = new AbortController();
|
|
532
|
+
controller.abort();
|
|
533
|
+
|
|
534
|
+
await expect(
|
|
535
|
+
handler!({ data: "test" }, hitlContext("tc-1", controller.signal)),
|
|
536
|
+
).rejects.toThrow("Human-in-the-loop interaction aborted");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("keeps parallel interrupts on the same tool independent", async () => {
|
|
540
|
+
const humanInTheLoop: VueHumanInTheLoop[] = [
|
|
541
|
+
{
|
|
542
|
+
name: "interactiveTool",
|
|
543
|
+
description: "Interactive tool",
|
|
544
|
+
parameters: z.object({ data: z.string() }),
|
|
545
|
+
render: HitlComponent,
|
|
546
|
+
},
|
|
547
|
+
];
|
|
548
|
+
|
|
549
|
+
const { getCore } = mountWithProvider(() => h("div"), { humanInTheLoop });
|
|
550
|
+
const handler = getCore().getTool({
|
|
551
|
+
toolName: "interactiveTool",
|
|
552
|
+
})?.handler;
|
|
553
|
+
|
|
554
|
+
const first = handler!({ data: "one" }, hitlContext("tc-1"));
|
|
555
|
+
const second = handler!({ data: "two" }, hitlContext("tc-2"));
|
|
556
|
+
|
|
557
|
+
let firstSettled = false;
|
|
558
|
+
void first.then(() => {
|
|
559
|
+
firstSettled = true;
|
|
560
|
+
});
|
|
561
|
+
let secondSettled = false;
|
|
562
|
+
void second.then(() => {
|
|
563
|
+
secondSettled = true;
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// Respond to the second tool call only.
|
|
567
|
+
const secondProps = renderHitl(getCore, "interactiveTool", {
|
|
568
|
+
name: "interactiveTool",
|
|
569
|
+
toolCallId: "tc-2",
|
|
570
|
+
args: { data: "two" },
|
|
571
|
+
status: ToolCallStatus.Executing,
|
|
572
|
+
result: undefined,
|
|
573
|
+
});
|
|
574
|
+
await secondProps.respond!("two-approved");
|
|
575
|
+
await expect(second).resolves.toBe("two-approved");
|
|
576
|
+
|
|
577
|
+
// The first is still waiting on its own user response.
|
|
578
|
+
await flush();
|
|
579
|
+
expect(firstSettled).toBe(false);
|
|
580
|
+
expect(secondSettled).toBe(true);
|
|
581
|
+
|
|
582
|
+
const firstProps = renderHitl(getCore, "interactiveTool", {
|
|
583
|
+
name: "interactiveTool",
|
|
584
|
+
toolCallId: "tc-1",
|
|
585
|
+
args: { data: "one" },
|
|
586
|
+
status: ToolCallStatus.Executing,
|
|
587
|
+
result: undefined,
|
|
588
|
+
});
|
|
589
|
+
await firstProps.respond!("one-approved");
|
|
590
|
+
await expect(first).resolves.toBe("one-approved");
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("passes the actual invoked tool name to a wildcard HITL renderer", () => {
|
|
594
|
+
const humanInTheLoop: VueHumanInTheLoop[] = [
|
|
595
|
+
{
|
|
596
|
+
name: "*",
|
|
597
|
+
description: "Approve any tool",
|
|
598
|
+
parameters: z.object({ data: z.string() }),
|
|
599
|
+
render: HitlComponent,
|
|
600
|
+
},
|
|
601
|
+
];
|
|
602
|
+
|
|
603
|
+
const { getCore } = mountWithProvider(() => h("div"), { humanInTheLoop });
|
|
604
|
+
|
|
605
|
+
const props = renderHitl(getCore, "*", {
|
|
606
|
+
name: "deleteFile",
|
|
607
|
+
toolCallId: "tc-1",
|
|
608
|
+
args: { data: "test" },
|
|
609
|
+
status: ToolCallStatus.Executing,
|
|
610
|
+
result: undefined,
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
expect(props.name).toBe("deleteFile");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("passes agentId through to the HITL renderer", () => {
|
|
617
|
+
const humanInTheLoop: VueHumanInTheLoop[] = [
|
|
618
|
+
{
|
|
619
|
+
name: "scopedTool",
|
|
620
|
+
description: "Scoped to one agent",
|
|
621
|
+
parameters: z.object({ data: z.string() }),
|
|
622
|
+
agentId: "specific-agent",
|
|
623
|
+
render: HitlComponent,
|
|
624
|
+
},
|
|
625
|
+
];
|
|
626
|
+
|
|
627
|
+
const { getCore } = mountWithProvider(() => h("div"), { humanInTheLoop });
|
|
628
|
+
|
|
629
|
+
// `VueHumanInTheLoopRenderProps` declares `agentId`, and the
|
|
630
|
+
// `useHumanInTheLoop` composable supplies it. The prop path must too.
|
|
631
|
+
const props = renderHitl(getCore, "scopedTool", {
|
|
632
|
+
name: "scopedTool",
|
|
633
|
+
toolCallId: "tc-1",
|
|
634
|
+
args: { data: "test" },
|
|
635
|
+
status: ToolCallStatus.Executing,
|
|
636
|
+
result: undefined,
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
expect(props.agentId).toBe("specific-agent");
|
|
640
|
+
});
|
|
400
641
|
});
|
|
401
642
|
|
|
402
643
|
describe("Combined tools functionality", () => {
|
|
@@ -497,7 +738,20 @@ describe("CopilotKitProvider", () => {
|
|
|
497
738
|
expect(frontendRenderTool).toBeDefined();
|
|
498
739
|
expect(humanRenderTool).toBeDefined();
|
|
499
740
|
expect(frontendRenderTool?.render).toStrictEqual(TestComponent1);
|
|
500
|
-
|
|
741
|
+
// A humanInTheLoop render is wrapped so `respond` can be injected, so it
|
|
742
|
+
// is not the component itself — assert it delegates to it.
|
|
743
|
+
const humanRender = humanRenderTool?.render as (
|
|
744
|
+
p: Record<string, unknown>,
|
|
745
|
+
) => VNode;
|
|
746
|
+
expect(
|
|
747
|
+
humanRender({
|
|
748
|
+
name: "humanRenderTool",
|
|
749
|
+
toolCallId: "tc-1",
|
|
750
|
+
args: { b: "value" },
|
|
751
|
+
status: ToolCallStatus.Executing,
|
|
752
|
+
result: undefined,
|
|
753
|
+
}).type,
|
|
754
|
+
).toStrictEqual(TestComponent2);
|
|
501
755
|
});
|
|
502
756
|
});
|
|
503
757
|
|