@copilotkit/vue 1.73.0 → 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.
Files changed (31) hide show
  1. package/dist/index.cjs +1 -1
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.mjs +42 -41
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/use-render-activity-message-6LwUTCNm.cjs +85 -0
  6. package/dist/use-render-activity-message-6LwUTCNm.cjs.map +1 -0
  7. package/dist/{use-render-activity-message-DI4kGqHF.js → use-render-activity-message-Di_L8HCv.js} +3513 -3468
  8. package/dist/use-render-activity-message-Di_L8HCv.js.map +1 -0
  9. package/dist/v2/components/chat/CopilotChat.vue.d.ts.map +1 -1
  10. package/dist/v2/components/chat/CopilotChatMessageView.vue.d.ts.map +1 -1
  11. package/dist/v2/hooks/use-human-in-the-loop.d.ts.map +1 -1
  12. package/dist/v2/index.cjs +1 -1
  13. package/dist/v2/index.mjs +1 -1
  14. package/dist/v2/providers/CopilotKitProvider.types.d.ts +25 -1
  15. package/dist/v2/providers/CopilotKitProvider.types.d.ts.map +1 -1
  16. package/dist/v2/providers/CopilotKitProvider.vue.d.ts.map +1 -1
  17. package/package.json +5 -5
  18. package/src/v2/components/chat/CopilotChat.vue +2 -8
  19. package/src/v2/components/chat/CopilotChatMessageView.vue +29 -3
  20. package/src/v2/components/chat/__tests__/CopilotChatMessageView.rowKeys.test.ts +140 -0
  21. package/src/v2/hooks/__tests__/use-human-in-the-loop.e2e.test.ts +32 -6
  22. package/src/v2/hooks/use-human-in-the-loop.ts +1 -0
  23. package/src/v2/providers/CopilotKitProvider.types.ts +28 -1
  24. package/src/v2/providers/CopilotKitProvider.vue +86 -6
  25. package/src/v2/providers/__tests__/CopilotKitProvider.hitl.e2e.test.ts +192 -0
  26. package/src/v2/providers/__tests__/CopilotKitProvider.messageFilter.test.ts +159 -0
  27. package/src/v2/providers/__tests__/CopilotKitProvider.test.ts +275 -21
  28. package/src/v2/providers/__tests__/CopilotKitProvider.wildcard.test.ts +19 -1
  29. package/dist/use-render-activity-message-C45MYgwB.cjs +0 -85
  30. package/dist/use-render-activity-message-C45MYgwB.cjs.map +0 -1
  31. package/dist/use-render-activity-message-DI4kGqHF.js.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 { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
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 = defineComponent({
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).toStrictEqual(TestComponent);
404
+ expect(typeof renderTool?.render).toBe("function");
366
405
  });
367
406
 
368
- it("creates placeholder handlers for humanInTheLoop tools", async () => {
369
- const TestComponent = defineComponent({
370
- setup() {
371
- return () => h("div", "Test");
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: TestComponent,
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 result = await handler!(
501
+ const controller = new AbortController();
502
+ const pending = handler!(
390
503
  { data: "test" },
391
- {} as unknown as FrontendToolHandlerContext,
504
+ hitlContext("tc-1", controller.signal),
392
505
  );
393
- expect(result).toBeUndefined();
394
- expect(consoleWarnSpy).toHaveBeenCalledWith(
395
- expect.stringContaining(
396
- "Human-in-the-loop tool 'interactiveTool' called",
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
- expect(humanRenderTool?.render).toStrictEqual(TestComponent2);
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