@copilotkit/vue 1.75.2 → 1.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PARITY.md +93 -35
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +275 -158
- package/dist/index.mjs.map +1 -1
- package/dist/use-render-activity-message-JCCE1lgH.cjs +79 -0
- package/dist/use-render-activity-message-JCCE1lgH.cjs.map +1 -0
- package/dist/use-render-activity-message-bPlnHjQV.js +12499 -0
- package/dist/use-render-activity-message-bPlnHjQV.js.map +1 -0
- package/dist/v2/components/MCPAppsActivityRenderer.d.ts +10 -42
- package/dist/v2/components/MCPAppsActivityRenderer.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 +8 -152
- package/dist/v2/components/chat/CopilotChatAssistantMessage.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/CopilotChatView.vue.d.ts +1 -1
- package/dist/v2/components/chat/CopilotPopupViewInternal.vue.d.ts.map +1 -1
- package/dist/v2/components/chat/copilotChatToolCallItemRender.d.ts.map +1 -1
- package/dist/v2/components/chat/index.d.ts +3 -3
- package/dist/v2/hooks/use-agent.d.ts.map +1 -1
- package/dist/v2/index.cjs +1 -1
- package/dist/v2/index.d.cts +2 -0
- package/dist/v2/index.d.mts +2 -0
- package/dist/v2/index.d.ts +2 -0
- package/dist/v2/index.d.ts.map +1 -1
- package/dist/v2/index.mjs +189 -72
- package/dist/v2/index.mjs.map +1 -1
- package/package.json +8 -7
- package/src/v2/__tests__/utils/test-helpers.ts +26 -6
- package/src/v2/components/MCPAppsActivityRenderer.ts +175 -658
- package/src/v2/components/__tests__/MCPAppsActivityRenderer.test.ts +39 -20
- package/src/v2/components/__tests__/MCPAppsActivityRendererContentError.test.ts +122 -0
- package/src/v2/components/__tests__/MCPAppsActivityRendererSeed.test.ts +109 -0
- package/src/v2/components/chat/CopilotChat.vue +100 -20
- package/src/v2/components/chat/CopilotPopupViewInternal.vue +1 -1
- package/src/v2/components/chat/__tests__/CopilotChat.clearOnFresh.test.ts +130 -2
- package/src/v2/components/chat/__tests__/CopilotChat.e2e.test.ts +6 -1
- package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.ts +22 -40
- package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.ts +12 -24
- package/src/v2/components/chat/__tests__/CopilotPopupView.slots.e2e.test.ts +20 -0
- package/src/v2/components/chat/copilotChatToolCallItemRender.ts +3 -2
- package/src/v2/hooks/__tests__/use-agent-headers.test.ts +83 -0
- package/src/v2/hooks/__tests__/use-agent-stability.test.ts +2 -0
- package/src/v2/hooks/__tests__/use-agent.e2e.test.ts +5 -3
- package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.ts +2 -2
- package/src/v2/hooks/use-agent.ts +3 -3
- package/src/v2/index.ts +5 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.renderCustomMessages.e2e.test.ts +6 -4
- package/vite.config.ts +7 -0
- package/dist/use-render-activity-message-CGdS03Xj.cjs +0 -85
- package/dist/use-render-activity-message-CGdS03Xj.cjs.map +0 -1
- package/dist/use-render-activity-message-XmuT9fKy.js +0 -9799
- package/dist/use-render-activity-message-XmuT9fKy.js.map +0 -1
|
@@ -48,6 +48,32 @@ async function flushAsync() {
|
|
|
48
48
|
await nextTick();
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Wait for the sandbox iframe to be configured by the shared session. The host
|
|
53
|
+
* mounts the iframe synchronously, but the bridge package is loaded through a
|
|
54
|
+
* dynamic import and the resource is fetched through the agent, so `srcdoc` (and
|
|
55
|
+
* the sandbox contract attributes) land a few macrotasks later.
|
|
56
|
+
*/
|
|
57
|
+
async function waitForSandboxIframe(wrapper: {
|
|
58
|
+
find: (selector: string) => { exists: () => boolean; element: Element };
|
|
59
|
+
}): Promise<HTMLIFrameElement> {
|
|
60
|
+
let iframe: HTMLIFrameElement | undefined;
|
|
61
|
+
await vi.waitFor(
|
|
62
|
+
() => {
|
|
63
|
+
const found = wrapper.find("iframe");
|
|
64
|
+
expect(found.exists()).toBe(true);
|
|
65
|
+
const element = found.element as HTMLIFrameElement;
|
|
66
|
+
expect(element.srcdoc).toBeTruthy();
|
|
67
|
+
iframe = element;
|
|
68
|
+
},
|
|
69
|
+
// The bridge arrives through a dynamic import, so this waits on real module
|
|
70
|
+
// loading rather than a microtask. `vi.waitFor`'s 1s default is enough in
|
|
71
|
+
// isolation but not when the whole monorepo's suites run in parallel.
|
|
72
|
+
{ timeout: 10_000, interval: 50 },
|
|
73
|
+
);
|
|
74
|
+
return iframe!;
|
|
75
|
+
}
|
|
76
|
+
|
|
51
77
|
describe("MCPAppsActivityRenderer", () => {
|
|
52
78
|
it("exports the expected activity type and schema", () => {
|
|
53
79
|
expect(MCPAppsActivityType).toBe("mcp-apps");
|
|
@@ -80,8 +106,8 @@ describe("MCPAppsActivityRenderer", () => {
|
|
|
80
106
|
},
|
|
81
107
|
message: {
|
|
82
108
|
id: "activity-1",
|
|
83
|
-
role: "
|
|
84
|
-
content:
|
|
109
|
+
role: "activity",
|
|
110
|
+
content: {},
|
|
85
111
|
activityType: MCPAppsActivityType,
|
|
86
112
|
},
|
|
87
113
|
agent: undefined,
|
|
@@ -123,8 +149,8 @@ describe("MCPAppsActivityRenderer", () => {
|
|
|
123
149
|
},
|
|
124
150
|
message: {
|
|
125
151
|
id: "activity-no-border",
|
|
126
|
-
role: "
|
|
127
|
-
content:
|
|
152
|
+
role: "activity",
|
|
153
|
+
content: {},
|
|
128
154
|
activityType: MCPAppsActivityType,
|
|
129
155
|
},
|
|
130
156
|
agent,
|
|
@@ -174,18 +200,15 @@ describe("MCPAppsActivityRenderer", () => {
|
|
|
174
200
|
},
|
|
175
201
|
message: {
|
|
176
202
|
id: "activity-csp",
|
|
177
|
-
role: "
|
|
178
|
-
content:
|
|
203
|
+
role: "activity",
|
|
204
|
+
content: {},
|
|
179
205
|
activityType: MCPAppsActivityType,
|
|
180
206
|
},
|
|
181
207
|
agent,
|
|
182
208
|
},
|
|
183
209
|
});
|
|
184
210
|
|
|
185
|
-
await
|
|
186
|
-
await flushAsync();
|
|
187
|
-
|
|
188
|
-
const iframe = wrapper.find("iframe").element as HTMLIFrameElement;
|
|
211
|
+
const iframe = await waitForSandboxIframe(wrapper);
|
|
189
212
|
expect(iframe.srcdoc).toContain("script-src");
|
|
190
213
|
expect(iframe.srcdoc).toContain("frame-src");
|
|
191
214
|
expect(iframe.srcdoc).toContain("https://widgets.example.com");
|
|
@@ -214,18 +237,15 @@ describe("MCPAppsActivityRenderer", () => {
|
|
|
214
237
|
},
|
|
215
238
|
message: {
|
|
216
239
|
id: "activity-default-csp",
|
|
217
|
-
role: "
|
|
218
|
-
content:
|
|
240
|
+
role: "activity",
|
|
241
|
+
content: {},
|
|
219
242
|
activityType: MCPAppsActivityType,
|
|
220
243
|
},
|
|
221
244
|
agent,
|
|
222
245
|
},
|
|
223
246
|
});
|
|
224
247
|
|
|
225
|
-
await
|
|
226
|
-
await flushAsync();
|
|
227
|
-
|
|
228
|
-
const iframe = wrapper.find("iframe").element as HTMLIFrameElement;
|
|
248
|
+
const iframe = await waitForSandboxIframe(wrapper);
|
|
229
249
|
expect(iframe.srcdoc).toContain(
|
|
230
250
|
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' 'unsafe-eval' blob: data: http://localhost:* https://localhost:*;",
|
|
231
251
|
);
|
|
@@ -260,16 +280,15 @@ describe("MCPAppsActivityRenderer", () => {
|
|
|
260
280
|
},
|
|
261
281
|
message: {
|
|
262
282
|
id: "activity-testid",
|
|
263
|
-
role: "
|
|
264
|
-
content:
|
|
283
|
+
role: "activity",
|
|
284
|
+
content: {},
|
|
265
285
|
activityType: MCPAppsActivityType,
|
|
266
286
|
},
|
|
267
287
|
agent,
|
|
268
288
|
},
|
|
269
289
|
});
|
|
270
290
|
|
|
271
|
-
await
|
|
272
|
-
await flushAsync();
|
|
291
|
+
await waitForSandboxIframe(wrapper);
|
|
273
292
|
|
|
274
293
|
const iframe = wrapper.find('iframe[data-testid="mcp-app-iframe"]');
|
|
275
294
|
expect(iframe.exists()).toBe(true);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A content rejection is RECOVERABLE: the session reports it through
|
|
3
|
+
* `onContentError` and, once valid content resumes, calls it again with `null`.
|
|
4
|
+
* The adapter must clear the message then - otherwise the widget starts working
|
|
5
|
+
* again while the host still shows a stale error until the next bind.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
8
|
+
import { nextTick } from "vue";
|
|
9
|
+
import { mount } from "@vue/test-utils";
|
|
10
|
+
import type { AbstractAgent } from "@ag-ui/client";
|
|
11
|
+
|
|
12
|
+
// Capture the hooks the adapter hands to the session, so the test can drive the
|
|
13
|
+
// recoverable/fatal channels directly.
|
|
14
|
+
const { bindMcpAppSpy, sessionSpy } = vi.hoisted(() => {
|
|
15
|
+
const session = {
|
|
16
|
+
sendToolInput: vi.fn(),
|
|
17
|
+
sendToolResult: vi.fn(),
|
|
18
|
+
syncContent: vi.fn(),
|
|
19
|
+
teardown: vi.fn(),
|
|
20
|
+
};
|
|
21
|
+
return { bindMcpAppSpy: vi.fn(() => session), sessionSpy: session };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock("@copilotkit/mcp-apps-renderer", () => ({
|
|
25
|
+
bindMcpApp: bindMcpAppSpy,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("../../providers/useCopilotKit", () => ({
|
|
29
|
+
useCopilotKit: () => ({
|
|
30
|
+
copilotkit: { value: { runAgent: vi.fn() } },
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
MCPAppsActivityRenderer,
|
|
36
|
+
MCPAppsActivityType,
|
|
37
|
+
} from "../MCPAppsActivityRenderer";
|
|
38
|
+
|
|
39
|
+
function createAgentMock(): AbstractAgent {
|
|
40
|
+
return {
|
|
41
|
+
threadId: "content-error-thread",
|
|
42
|
+
isRunning: false,
|
|
43
|
+
messages: [],
|
|
44
|
+
runAgent: vi.fn().mockResolvedValue({ result: { contents: [] } }),
|
|
45
|
+
addMessage: vi.fn(),
|
|
46
|
+
subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })),
|
|
47
|
+
} as unknown as AbstractAgent;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mountRenderer() {
|
|
51
|
+
return mount(MCPAppsActivityRenderer, {
|
|
52
|
+
props: {
|
|
53
|
+
activityType: MCPAppsActivityType,
|
|
54
|
+
content: {
|
|
55
|
+
resourceUri: "ui://server/app",
|
|
56
|
+
serverHash: "hash",
|
|
57
|
+
result: { content: [] },
|
|
58
|
+
},
|
|
59
|
+
message: {
|
|
60
|
+
id: "activity-1",
|
|
61
|
+
role: "activity",
|
|
62
|
+
content: {},
|
|
63
|
+
activityType: MCPAppsActivityType,
|
|
64
|
+
},
|
|
65
|
+
agent: createAgentMock(),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The hooks object passed to the most recent bindMcpApp call. */
|
|
71
|
+
async function capturedHooks() {
|
|
72
|
+
await vi.waitFor(() => expect(bindMcpAppSpy).toHaveBeenCalled());
|
|
73
|
+
const options = bindMcpAppSpy.mock.calls.at(-1)![0] as {
|
|
74
|
+
hooks?: {
|
|
75
|
+
onContentError?: (err: Error | null) => void;
|
|
76
|
+
onError?: (err: Error) => void;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
return options.hooks!;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("MCPAppsActivityRenderer content error recovery", () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
vi.clearAllMocks();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("shows a rejected-content error and clears it when valid content resumes", async () => {
|
|
88
|
+
const wrapper = mountRenderer();
|
|
89
|
+
const hooks = await capturedHooks();
|
|
90
|
+
|
|
91
|
+
// valid -> nothing shown
|
|
92
|
+
expect(wrapper.text()).not.toContain("Error:");
|
|
93
|
+
|
|
94
|
+
// invalid -> the rejection is visible
|
|
95
|
+
hooks.onContentError!(new Error("content does not match the schema"));
|
|
96
|
+
await nextTick();
|
|
97
|
+
expect(wrapper.text()).toContain("content does not match the schema");
|
|
98
|
+
|
|
99
|
+
// valid again -> the message disappears
|
|
100
|
+
hooks.onContentError!(null);
|
|
101
|
+
await nextTick();
|
|
102
|
+
expect(wrapper.text()).not.toContain("content does not match the schema");
|
|
103
|
+
expect(wrapper.text()).not.toContain("Error:");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps a fatal session error on screen", async () => {
|
|
107
|
+
const wrapper = mountRenderer();
|
|
108
|
+
const hooks = await capturedHooks();
|
|
109
|
+
|
|
110
|
+
hooks.onError!(new Error("No resource content in response"));
|
|
111
|
+
await nextTick();
|
|
112
|
+
expect(wrapper.text()).toContain("No resource content in response");
|
|
113
|
+
|
|
114
|
+
// Clearing the recoverable channel must not clear a fatal error.
|
|
115
|
+
hooks.onContentError!(null);
|
|
116
|
+
await nextTick();
|
|
117
|
+
expect(wrapper.text()).toContain("No resource content in response");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Referenced so the session stub is not tree-shaken out of the mock factory.
|
|
122
|
+
void sessionSpy;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue counterpart of react-core's `MCPAppsActivityRendererSeed.test.tsx`.
|
|
3
|
+
*
|
|
4
|
+
* The session is self-driving: given a `messageId`, it subscribes to the agent's
|
|
5
|
+
* message store and pushes tool input/result to the widget itself. That store is
|
|
6
|
+
* authoritative only for activities that live in it - a host can render an
|
|
7
|
+
* activity from an EXTERNAL messages list (absent from `agent.messages`), whose
|
|
8
|
+
* props may never change after mount. For those, the adapter must seed the
|
|
9
|
+
* widget by calling `session.syncContent(props.content)` immediately after
|
|
10
|
+
* binding, otherwise the widget would never receive its initial content.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
13
|
+
import { mount } from "@vue/test-utils";
|
|
14
|
+
import type { AbstractAgent } from "@ag-ui/client";
|
|
15
|
+
|
|
16
|
+
// Capture the bindMcpApp options and the returned session's calls. Hoisted so
|
|
17
|
+
// the vi.mock factory (which is itself hoisted above imports) can reference them.
|
|
18
|
+
const { bindMcpAppSpy, sessionSpy } = vi.hoisted(() => {
|
|
19
|
+
const session = {
|
|
20
|
+
sendToolInput: vi.fn(),
|
|
21
|
+
sendToolResult: vi.fn(),
|
|
22
|
+
syncContent: vi.fn(),
|
|
23
|
+
teardown: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
const bind = vi.fn(() => session);
|
|
26
|
+
return { bindMcpAppSpy: bind, sessionSpy: session };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Only the bridge-carrying root entry is mocked; the bridge-free `/activity`
|
|
30
|
+
// entry (activity type + content schema) stays real, as in the React test.
|
|
31
|
+
vi.mock("@copilotkit/mcp-apps-renderer", () => ({
|
|
32
|
+
bindMcpApp: bindMcpAppSpy,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("../../providers/useCopilotKit", () => ({
|
|
36
|
+
useCopilotKit: () => ({
|
|
37
|
+
copilotkit: { value: { runAgent: vi.fn() } },
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
MCPAppsActivityRenderer,
|
|
43
|
+
MCPAppsActivityType,
|
|
44
|
+
} from "../MCPAppsActivityRenderer";
|
|
45
|
+
import type { MCPAppsActivityContent } from "../MCPAppsActivityRenderer";
|
|
46
|
+
|
|
47
|
+
function createAgentMock(): AbstractAgent {
|
|
48
|
+
return {
|
|
49
|
+
threadId: "seed-thread",
|
|
50
|
+
isRunning: false,
|
|
51
|
+
// The activity under test is NOT in the store: this is the external
|
|
52
|
+
// messages-list case the seed exists for.
|
|
53
|
+
messages: [],
|
|
54
|
+
runAgent: vi.fn().mockResolvedValue({ result: { contents: [] } }),
|
|
55
|
+
addMessage: vi.fn(),
|
|
56
|
+
subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })),
|
|
57
|
+
} as unknown as AbstractAgent;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const content: MCPAppsActivityContent = {
|
|
61
|
+
resourceUri: "ui://server/external",
|
|
62
|
+
serverHash: "hash-external",
|
|
63
|
+
toolInput: { city: "Paris" },
|
|
64
|
+
result: { content: [{ type: "text", text: "Forecast" }], isError: false },
|
|
65
|
+
} as MCPAppsActivityContent;
|
|
66
|
+
|
|
67
|
+
function mountRenderer() {
|
|
68
|
+
return mount(MCPAppsActivityRenderer, {
|
|
69
|
+
props: {
|
|
70
|
+
activityType: MCPAppsActivityType,
|
|
71
|
+
content,
|
|
72
|
+
message: {
|
|
73
|
+
id: "external-activity-1",
|
|
74
|
+
role: "activity",
|
|
75
|
+
content: {},
|
|
76
|
+
activityType: MCPAppsActivityType,
|
|
77
|
+
},
|
|
78
|
+
agent: createAgentMock(),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("MCPAppsActivityRenderer external-activity seed", () => {
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
vi.clearAllMocks();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("passes the activity messageId to the bridge for self-subscription", async () => {
|
|
89
|
+
mountRenderer();
|
|
90
|
+
|
|
91
|
+
await vi.waitFor(() => expect(bindMcpAppSpy).toHaveBeenCalledTimes(1));
|
|
92
|
+
const opts = bindMcpAppSpy.mock.calls[0]![0];
|
|
93
|
+
expect(opts.messageId).toBe("external-activity-1");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("seeds the widget with the initial tool input/result right after binding, even when props never change", async () => {
|
|
97
|
+
mountRenderer();
|
|
98
|
+
|
|
99
|
+
// The content watcher does not re-run for unchanged props, so without the
|
|
100
|
+
// explicit seed the widget would never receive this initial content.
|
|
101
|
+
await vi.waitFor(() => expect(sessionSpy.syncContent).toHaveBeenCalled());
|
|
102
|
+
expect(sessionSpy.syncContent).toHaveBeenCalledWith(
|
|
103
|
+
expect.objectContaining({
|
|
104
|
+
toolInput: { city: "Paris" },
|
|
105
|
+
resourceUri: "ui://server/external",
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
AGUIConnectNotImplementedError,
|
|
4
|
-
AbstractAgent,
|
|
5
|
-
HttpAgent,
|
|
6
|
-
} from "@ag-ui/client";
|
|
2
|
+
import { AGUIConnectNotImplementedError, AbstractAgent } from "@ag-ui/client";
|
|
7
3
|
import {
|
|
8
4
|
createAttachmentContent,
|
|
9
5
|
DEFAULT_AGENT_ID,
|
|
@@ -21,7 +17,7 @@ import {
|
|
|
21
17
|
useSlots,
|
|
22
18
|
watch,
|
|
23
19
|
} from "vue";
|
|
24
|
-
import { CopilotKitCoreErrorCode } from "@copilotkit/core";
|
|
20
|
+
import { CopilotKitCoreErrorCode, ɵisHttpAgent } from "@copilotkit/core";
|
|
25
21
|
import type { Suggestion } from "@copilotkit/core";
|
|
26
22
|
import CopilotChatConfigurationProvider from "../../providers/CopilotChatConfigurationProvider.vue";
|
|
27
23
|
import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfiguration";
|
|
@@ -111,9 +107,15 @@ type ActiveConnectCycle = {
|
|
|
111
107
|
inspectorRequestId: string | null;
|
|
112
108
|
abortController: AbortController;
|
|
113
109
|
detached: boolean;
|
|
110
|
+
clearDiscardedBaseline?: () => void;
|
|
114
111
|
};
|
|
115
112
|
|
|
116
113
|
const activeConnectCycle = shallowRef<ActiveConnectCycle | null>(null);
|
|
114
|
+
let lastConnectCycle: ActiveConnectCycle | null = null;
|
|
115
|
+
const detachPromises = new WeakMap<AbstractAgent, Promise<void>>();
|
|
116
|
+
// Agent clones can share cursors. Finish every old reset before connecting again.
|
|
117
|
+
let pendingTeardown = Promise.resolve();
|
|
118
|
+
const baselineThreadIds = new WeakMap<AbstractAgent, string>();
|
|
117
119
|
|
|
118
120
|
const resolvedAgentId = computed(
|
|
119
121
|
() => props.agentId ?? existingConfig.value?.agentId ?? DEFAULT_AGENT_ID,
|
|
@@ -379,6 +381,7 @@ watch(
|
|
|
379
381
|
// React does — makes the ordering unconditional instead of depending on
|
|
380
382
|
// watcher scheduling. Non-explicit threads skip /connect below, but the first
|
|
381
383
|
// runAgent still has to ship the thread the UI is rendering.
|
|
384
|
+
baselineThreadIds.set(currentAgent, currentAgent.threadId);
|
|
382
385
|
currentAgent.threadId = threadId;
|
|
383
386
|
|
|
384
387
|
// When the caller hasn't picked a specific thread, resolvedThreadId is
|
|
@@ -421,10 +424,6 @@ watch(
|
|
|
421
424
|
cycle = existingCycle;
|
|
422
425
|
} else {
|
|
423
426
|
const connectAbortController = new AbortController();
|
|
424
|
-
if (currentAgent instanceof HttpAgent) {
|
|
425
|
-
currentAgent.abortController = connectAbortController;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
427
|
cycle = {
|
|
429
428
|
core: core as object,
|
|
430
429
|
agent: currentAgent,
|
|
@@ -434,11 +433,18 @@ watch(
|
|
|
434
433
|
detached: false,
|
|
435
434
|
};
|
|
436
435
|
activeConnectCycle.value = cycle;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
436
|
+
lastConnectCycle = cycle;
|
|
437
|
+
|
|
438
|
+
void pendingTeardown
|
|
439
|
+
.then(() => {
|
|
440
|
+
if (cycle.detached) return;
|
|
441
|
+
if (ɵisHttpAgent(currentAgent))
|
|
442
|
+
currentAgent.abortController = connectAbortController;
|
|
443
|
+
return core.connectAgent({ agent: currentAgent });
|
|
444
|
+
})
|
|
440
445
|
.catch((error: unknown) => {
|
|
441
446
|
if (cycle.detached) {
|
|
447
|
+
cycle.clearDiscardedBaseline?.();
|
|
442
448
|
return;
|
|
443
449
|
}
|
|
444
450
|
if (error instanceof AGUIConnectNotImplementedError) {
|
|
@@ -460,6 +466,7 @@ watch(
|
|
|
460
466
|
// can briefly render against an incompletely-laid-out message
|
|
461
467
|
// tree and visibly snap once the last text chunk lands.
|
|
462
468
|
if (cycle.detached) {
|
|
469
|
+
cycle.clearDiscardedBaseline?.();
|
|
463
470
|
return;
|
|
464
471
|
}
|
|
465
472
|
const raf =
|
|
@@ -490,7 +497,17 @@ watch(
|
|
|
490
497
|
|
|
491
498
|
activeCycle.detached = true;
|
|
492
499
|
activeCycle.abortController.abort();
|
|
493
|
-
|
|
500
|
+
const detach = activeCycle.agent
|
|
501
|
+
.detachActiveRun()
|
|
502
|
+
.catch((error: unknown) => {
|
|
503
|
+
console.error("CopilotChat: detachActiveRun failed", error);
|
|
504
|
+
});
|
|
505
|
+
detachPromises.set(activeCycle.agent, detach);
|
|
506
|
+
pendingTeardown = Promise.all([pendingTeardown, detach]).then(() => {});
|
|
507
|
+
void detach.then(() => {
|
|
508
|
+
if (detachPromises.get(activeCycle.agent) === detach)
|
|
509
|
+
detachPromises.delete(activeCycle.agent);
|
|
510
|
+
});
|
|
494
511
|
activeConnectCycle.value = null;
|
|
495
512
|
});
|
|
496
513
|
},
|
|
@@ -502,17 +519,80 @@ watch(
|
|
|
502
519
|
// replay their history via the /connect cycle above, and the very first
|
|
503
520
|
// resolution (mount) or an agent-store swap that keeps the same thread id
|
|
504
521
|
// must never clear — only a real fresh-thread transition does.
|
|
505
|
-
|
|
522
|
+
let lastFreshSelection:
|
|
523
|
+
| { threadId: string; agent: AbstractAgent | null }
|
|
524
|
+
| undefined;
|
|
506
525
|
watch(
|
|
507
526
|
[resolvedThreadId, hasExplicitThreadId, () => agent.value],
|
|
508
|
-
([threadId, isExplicit, currentAgent]) => {
|
|
509
|
-
const previous =
|
|
510
|
-
|
|
527
|
+
([threadId, isExplicit, currentAgent], _old, onCleanup) => {
|
|
528
|
+
const previous = lastFreshSelection;
|
|
529
|
+
const selection = { threadId, agent: currentAgent };
|
|
530
|
+
lastFreshSelection = selection;
|
|
511
531
|
if (!currentAgent) return;
|
|
512
532
|
if (isExplicit) return;
|
|
513
533
|
if (previous === undefined) return;
|
|
514
|
-
if (threadId === previous) return;
|
|
515
|
-
|
|
534
|
+
if (threadId === previous.threadId) return;
|
|
535
|
+
let active = true;
|
|
536
|
+
let resetPending = true;
|
|
537
|
+
onCleanup(() => {
|
|
538
|
+
active = false;
|
|
539
|
+
});
|
|
540
|
+
const discardedThreadId =
|
|
541
|
+
previous.agent === currentAgent
|
|
542
|
+
? previous.threadId
|
|
543
|
+
: baselineThreadIds.get(currentAgent);
|
|
544
|
+
const clearCursor = () => {
|
|
545
|
+
if (!discardedThreadId) return;
|
|
546
|
+
if (
|
|
547
|
+
"clearReplayCursor" in currentAgent &&
|
|
548
|
+
typeof currentAgent.clearReplayCursor === "function"
|
|
549
|
+
)
|
|
550
|
+
currentAgent.clearReplayCursor(discardedThreadId);
|
|
551
|
+
if (
|
|
552
|
+
"clearReconnectCursor" in currentAgent &&
|
|
553
|
+
typeof currentAgent.clearReconnectCursor === "function"
|
|
554
|
+
)
|
|
555
|
+
currentAgent.clearReconnectCursor(discardedThreadId);
|
|
556
|
+
};
|
|
557
|
+
const clearDiscardedBaseline = () => {
|
|
558
|
+
if (
|
|
559
|
+
!active ||
|
|
560
|
+
!resetPending ||
|
|
561
|
+
isUnmounting.value ||
|
|
562
|
+
lastFreshSelection !== selection ||
|
|
563
|
+
agent.value !== currentAgent
|
|
564
|
+
)
|
|
565
|
+
return;
|
|
566
|
+
currentAgent.setMessages([]);
|
|
567
|
+
currentAgent.setState({});
|
|
568
|
+
currentAgent.pendingInterrupts = [];
|
|
569
|
+
clearCursor();
|
|
570
|
+
};
|
|
571
|
+
if (
|
|
572
|
+
lastConnectCycle?.agent === currentAgent &&
|
|
573
|
+
lastConnectCycle.threadId === discardedThreadId
|
|
574
|
+
)
|
|
575
|
+
lastConnectCycle.clearDiscardedBaseline = clearDiscardedBaseline;
|
|
576
|
+
const detach =
|
|
577
|
+
detachPromises.get(currentAgent) ?? currentAgent.detachActiveRun();
|
|
578
|
+
detachPromises.set(currentAgent, detach);
|
|
579
|
+
const finishReset = () => {
|
|
580
|
+
if (!resetPending) return;
|
|
581
|
+
// The cursor belongs to the discarded view, even after a fast reopen.
|
|
582
|
+
clearCursor();
|
|
583
|
+
clearDiscardedBaseline();
|
|
584
|
+
resetPending = false;
|
|
585
|
+
};
|
|
586
|
+
clearDiscardedBaseline();
|
|
587
|
+
pendingTeardown = Promise.all([pendingTeardown, detach])
|
|
588
|
+
.then(finishReset)
|
|
589
|
+
.catch((error: unknown) => {
|
|
590
|
+
console.error("CopilotChat: detachActiveRun failed", error);
|
|
591
|
+
})
|
|
592
|
+
.finally(() => {
|
|
593
|
+
if (detachPromises.get(currentAgent) === detach)
|
|
594
|
+
detachPromises.delete(currentAgent);
|
|
595
|
+
});
|
|
516
596
|
},
|
|
517
597
|
{ immediate: true },
|
|
518
598
|
);
|
|
@@ -352,7 +352,7 @@ onBeforeUnmount(() => {
|
|
|
352
352
|
<div
|
|
353
353
|
v-if="isRendered"
|
|
354
354
|
data-copilotkit
|
|
355
|
-
class="cpk:fixed cpk:inset-0 cpk:z-[1200] cpk:flex cpk:max-w-full cpk:flex-col cpk:items-stretch cpk:md:inset-auto cpk:md:bottom-24 cpk:md:right-6 cpk:md:items-end cpk:md:gap-4"
|
|
355
|
+
class="cpk:fixed cpk:inset-0 cpk:z-[1200] cpk:flex cpk:max-w-full cpk:flex-col cpk:items-stretch cpk:bg-transparent cpk:md:inset-auto cpk:md:bottom-24 cpk:md:right-6 cpk:md:items-end cpk:md:gap-4"
|
|
356
356
|
>
|
|
357
357
|
<div
|
|
358
358
|
ref="containerRef"
|