@assistant-ui/react 0.15.1 → 0.15.3
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/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +43 -47
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/client/InMemoryThreadList.d.ts.map +1 -1
- package/dist/client/InMemoryThreadList.js +199 -176
- package/dist/client/InMemoryThreadList.js.map +1 -1
- package/dist/client/SingleThreadList.d.ts.map +1 -1
- package/dist/client/SingleThreadList.js +56 -48
- package/dist/client/SingleThreadList.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/legacy-runtime/cloud/auiV0.d.ts +26 -2
- package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.js +29 -8
- package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +25 -14
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.js +17 -6
- package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +6 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.d.ts +4 -2
- package/dist/primitives/suggestion/SuggestionTrigger.d.ts.map +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.js +21 -42
- package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
- package/dist/primitives/thread/ThreadSuggestion.d.ts +4 -2
- package/dist/primitives/thread/ThreadSuggestion.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/package.json +6 -6
- package/src/client/ExternalThread.ts +5 -16
- package/src/client/InMemoryThreadList.ts +27 -7
- package/src/client/SingleThreadList.ts +10 -2
- package/src/index.ts +1 -0
- package/src/legacy-runtime/cloud/auiV0.ts +59 -14
- package/src/mcp-apps/McpAppRenderer.test.tsx +262 -3
- package/src/mcp-apps/McpAppRenderer.tsx +27 -15
- package/src/mcp-apps/McpAppsRemoteHost.ts +18 -9
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -1
- package/src/primitives/suggestion/SuggestionTrigger.ts +11 -32
- package/src/primitives/thread/ThreadSuggestion.ts +2 -1
- package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +115 -0
- package/src/tests/external-thread-parity.test.tsx +108 -16
- package/src/tests/threadListItemIsRunning.test.tsx +198 -0
|
@@ -29,14 +29,24 @@ type ThreadData = {
|
|
|
29
29
|
// ThreadListItem Client
|
|
30
30
|
const useThreadListItemClient = (props: {
|
|
31
31
|
data: ThreadData;
|
|
32
|
+
isRunning: boolean;
|
|
32
33
|
onSwitchTo: () => void;
|
|
34
|
+
onRename: (title: string) => void;
|
|
33
35
|
onUpdateCustom: (custom: Record<string, unknown> | undefined) => void;
|
|
34
36
|
onArchive: () => void;
|
|
35
37
|
onUnarchive: () => void;
|
|
36
38
|
onDelete: () => void;
|
|
37
39
|
}): ClientOutput<"threadListItem"> => {
|
|
38
|
-
const {
|
|
39
|
-
|
|
40
|
+
const {
|
|
41
|
+
data,
|
|
42
|
+
isRunning,
|
|
43
|
+
onSwitchTo,
|
|
44
|
+
onRename,
|
|
45
|
+
onUpdateCustom,
|
|
46
|
+
onArchive,
|
|
47
|
+
onUnarchive,
|
|
48
|
+
onDelete,
|
|
49
|
+
} = props;
|
|
40
50
|
const state = useMemo(
|
|
41
51
|
() => ({
|
|
42
52
|
id: data.id,
|
|
@@ -45,14 +55,15 @@ const useThreadListItemClient = (props: {
|
|
|
45
55
|
title: data.title,
|
|
46
56
|
status: data.status,
|
|
47
57
|
custom: data.custom,
|
|
58
|
+
isRunning,
|
|
48
59
|
}),
|
|
49
|
-
[data.id, data.title, data.status, data.custom],
|
|
60
|
+
[data.id, data.title, data.status, data.custom, isRunning],
|
|
50
61
|
);
|
|
51
62
|
|
|
52
63
|
return {
|
|
53
64
|
getState: () => state,
|
|
54
65
|
switchTo: onSwitchTo,
|
|
55
|
-
rename:
|
|
66
|
+
rename: onRename,
|
|
56
67
|
updateCustom: onUpdateCustom,
|
|
57
68
|
archive: onArchive,
|
|
58
69
|
unarchive: onUnarchive,
|
|
@@ -85,6 +96,12 @@ const useInMemoryThreadList = (
|
|
|
85
96
|
onSwitchToThread?.(threadId);
|
|
86
97
|
};
|
|
87
98
|
|
|
99
|
+
const handleRename = (threadId: string, title: string) => {
|
|
100
|
+
setThreads((prev) =>
|
|
101
|
+
prev.map((t) => (t.id === threadId ? { ...t, title } : t)),
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
88
105
|
const handleArchive = (threadId: string) => {
|
|
89
106
|
setThreads((prev) =>
|
|
90
107
|
prev.map((t) =>
|
|
@@ -128,13 +145,18 @@ const useInMemoryThreadList = (
|
|
|
128
145
|
onSwitchToNewThread?.();
|
|
129
146
|
};
|
|
130
147
|
|
|
148
|
+
// Only the main thread is mounted, so it is the only thread that can run.
|
|
149
|
+
const mainThreadClient = useClientResource(threadFactory(mainThreadId));
|
|
150
|
+
|
|
131
151
|
const threadListItems = useClientLookup(
|
|
132
152
|
threads.map((t) =>
|
|
133
153
|
withKey(
|
|
134
154
|
t.id,
|
|
135
155
|
ThreadListItemClient({
|
|
136
156
|
data: t,
|
|
157
|
+
isRunning: t.id === mainThreadId && mainThreadClient.state.isRunning,
|
|
137
158
|
onSwitchTo: () => handleSwitchToThread(t.id),
|
|
159
|
+
onRename: (title) => handleRename(t.id, title),
|
|
138
160
|
onUpdateCustom: (custom) => handleUpdateCustom(t.id, custom),
|
|
139
161
|
onArchive: () => handleArchive(t.id),
|
|
140
162
|
onUnarchive: () => handleUnarchive(t.id),
|
|
@@ -144,9 +166,6 @@ const useInMemoryThreadList = (
|
|
|
144
166
|
),
|
|
145
167
|
);
|
|
146
168
|
|
|
147
|
-
// Create the main thread
|
|
148
|
-
const mainThreadClient = useClientResource(threadFactory(mainThreadId));
|
|
149
|
-
|
|
150
169
|
const state = useMemo(() => {
|
|
151
170
|
const regularThreads = threads.filter((t) => t.status === "regular");
|
|
152
171
|
const archivedThreads = threads.filter((t) => t.status === "archived");
|
|
@@ -170,6 +189,7 @@ const useInMemoryThreadList = (
|
|
|
170
189
|
switchToNewThread: handleSwitchToNewThread,
|
|
171
190
|
getLoadThreadsPromise: () => RESOLVED_PROMISE,
|
|
172
191
|
reload: () => RESOLVED_PROMISE,
|
|
192
|
+
reloadMainThread: () => RESOLVED_PROMISE,
|
|
173
193
|
loadMore: () => RESOLVED_PROMISE,
|
|
174
194
|
item: (selector) => {
|
|
175
195
|
if (selector === "main") {
|
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
const RESOLVED_PROMISE = Promise.resolve();
|
|
10
10
|
const THREAD_ID = "default";
|
|
11
11
|
|
|
12
|
-
const useSingleThreadListItem = (
|
|
12
|
+
const useSingleThreadListItem = ({
|
|
13
|
+
isRunning,
|
|
14
|
+
}: {
|
|
15
|
+
isRunning: boolean;
|
|
16
|
+
}): ClientOutput<"threadListItem"> => {
|
|
13
17
|
const [custom, setCustom] = useState<Record<string, unknown> | undefined>();
|
|
14
18
|
|
|
15
19
|
return {
|
|
@@ -20,6 +24,7 @@ const useSingleThreadListItem = (): ClientOutput<"threadListItem"> => {
|
|
|
20
24
|
title: undefined,
|
|
21
25
|
status: "regular",
|
|
22
26
|
custom,
|
|
27
|
+
isRunning,
|
|
23
28
|
}),
|
|
24
29
|
switchTo: () => {},
|
|
25
30
|
rename: () => {},
|
|
@@ -47,8 +52,10 @@ type SingleThreadListProps = {
|
|
|
47
52
|
const useSingleThreadList = ({
|
|
48
53
|
thread,
|
|
49
54
|
}: SingleThreadListProps): ClientOutput<"threads"> => {
|
|
50
|
-
const itemClient = useClientResource(SingleThreadListItem());
|
|
51
55
|
const threadClient = useClientResource(thread);
|
|
56
|
+
const itemClient = useClientResource(
|
|
57
|
+
SingleThreadListItem({ isRunning: threadClient.state.isRunning }),
|
|
58
|
+
);
|
|
52
59
|
|
|
53
60
|
const state = useMemo(
|
|
54
61
|
() => ({
|
|
@@ -75,6 +82,7 @@ const useSingleThreadList = ({
|
|
|
75
82
|
},
|
|
76
83
|
getLoadThreadsPromise: () => RESOLVED_PROMISE,
|
|
77
84
|
reload: () => RESOLVED_PROMISE,
|
|
85
|
+
reloadMainThread: () => RESOLVED_PROMISE,
|
|
78
86
|
loadMore: () => RESOLVED_PROMISE,
|
|
79
87
|
item: (selector) => {
|
|
80
88
|
if (
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CompleteAttachment,
|
|
3
|
-
DataMessagePart,
|
|
4
|
-
FileMessagePart,
|
|
5
|
-
ImageMessagePart,
|
|
6
3
|
MessageStatus,
|
|
7
4
|
SourceProviderMetadata,
|
|
8
5
|
ThreadMessage,
|
|
9
|
-
TextMessagePart,
|
|
10
6
|
ToolApprovalOption,
|
|
11
|
-
Unstable_AudioMessagePart,
|
|
12
7
|
} from "@assistant-ui/core";
|
|
13
8
|
import { fromThreadMessageLike } from "../runtime-cores/external-store/ThreadMessageLike";
|
|
14
9
|
import type { CloudMessage } from "assistant-cloud";
|
|
@@ -82,14 +77,38 @@ type AuiV0MessagePart =
|
|
|
82
77
|
readonly data: string;
|
|
83
78
|
readonly mimeType: string;
|
|
84
79
|
readonly filename?: string;
|
|
80
|
+
readonly sourceType?: "url" | "id";
|
|
85
81
|
};
|
|
86
82
|
|
|
87
83
|
type AuiV0AttachmentPart =
|
|
88
|
-
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
|
84
|
+
| {
|
|
85
|
+
readonly type: "text";
|
|
86
|
+
readonly text: string;
|
|
87
|
+
}
|
|
88
|
+
| {
|
|
89
|
+
readonly type: "image";
|
|
90
|
+
readonly image: string;
|
|
91
|
+
readonly filename?: string;
|
|
92
|
+
}
|
|
93
|
+
| {
|
|
94
|
+
readonly type: "file";
|
|
95
|
+
readonly data: string;
|
|
96
|
+
readonly mimeType: string;
|
|
97
|
+
readonly filename?: string;
|
|
98
|
+
readonly sourceType?: "url" | "id";
|
|
99
|
+
}
|
|
100
|
+
| {
|
|
101
|
+
readonly type: "audio";
|
|
102
|
+
readonly audio: {
|
|
103
|
+
readonly data: string;
|
|
104
|
+
readonly format: "mp3" | "wav";
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
| {
|
|
108
|
+
readonly type: "data";
|
|
109
|
+
readonly name: string;
|
|
110
|
+
readonly data: ReadonlyJSONValue;
|
|
111
|
+
};
|
|
93
112
|
|
|
94
113
|
type AuiV0Attachment = {
|
|
95
114
|
readonly id: string;
|
|
@@ -125,16 +144,41 @@ const encodeAttachmentPart = (
|
|
|
125
144
|
const type = part.type;
|
|
126
145
|
switch (type) {
|
|
127
146
|
case "text":
|
|
147
|
+
return { type: "text", text: part.text };
|
|
148
|
+
|
|
128
149
|
case "image":
|
|
150
|
+
return {
|
|
151
|
+
type: "image",
|
|
152
|
+
image: part.image,
|
|
153
|
+
...(part.filename != null ? { filename: part.filename } : undefined),
|
|
154
|
+
};
|
|
155
|
+
|
|
129
156
|
case "file":
|
|
157
|
+
return {
|
|
158
|
+
type: "file",
|
|
159
|
+
data: part.data,
|
|
160
|
+
mimeType: part.mimeType,
|
|
161
|
+
...(part.filename != null ? { filename: part.filename } : undefined),
|
|
162
|
+
...(part.sourceType != null
|
|
163
|
+
? { sourceType: part.sourceType }
|
|
164
|
+
: undefined),
|
|
165
|
+
};
|
|
166
|
+
|
|
130
167
|
case "audio":
|
|
131
|
-
return
|
|
168
|
+
return {
|
|
169
|
+
type: "audio",
|
|
170
|
+
audio: { data: part.audio.data, format: part.audio.format },
|
|
171
|
+
};
|
|
132
172
|
|
|
133
173
|
case "data": {
|
|
134
174
|
if (!isJSONValue(part.data)) {
|
|
135
175
|
console.warn(`attachment data is not JSON! ${JSON.stringify(part)}`);
|
|
136
176
|
}
|
|
137
|
-
return {
|
|
177
|
+
return {
|
|
178
|
+
type: "data",
|
|
179
|
+
name: part.name,
|
|
180
|
+
data: part.data as ReadonlyJSONValue,
|
|
181
|
+
};
|
|
138
182
|
}
|
|
139
183
|
|
|
140
184
|
default: {
|
|
@@ -213,7 +257,7 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
|
|
|
213
257
|
};
|
|
214
258
|
|
|
215
259
|
case "tool-call": {
|
|
216
|
-
if (!isJSONValue(part.result)) {
|
|
260
|
+
if (part.result !== undefined && !isJSONValue(part.result)) {
|
|
217
261
|
console.warn(
|
|
218
262
|
`tool-call result is not JSON! ${JSON.stringify(part)}`,
|
|
219
263
|
);
|
|
@@ -225,7 +269,7 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
|
|
|
225
269
|
...(JSON.stringify(part.args) === part.argsText
|
|
226
270
|
? { args: part.args }
|
|
227
271
|
: { argsText: part.argsText }),
|
|
228
|
-
...(part.result
|
|
272
|
+
...(part.result !== undefined
|
|
229
273
|
? { result: part.result as ReadonlyJSONValue }
|
|
230
274
|
: undefined),
|
|
231
275
|
...(part.isError ? { isError: true } : undefined),
|
|
@@ -242,6 +286,7 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
|
|
|
242
286
|
data: part.data,
|
|
243
287
|
mimeType: part.mimeType,
|
|
244
288
|
...(part.filename ? { filename: part.filename } : undefined),
|
|
289
|
+
...(part.sourceType ? { sourceType: part.sourceType } : undefined),
|
|
245
290
|
};
|
|
246
291
|
|
|
247
292
|
default: {
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { render, waitFor } from "@testing-library/react";
|
|
3
|
-
import { resource, useResource } from "@assistant-ui/tap";
|
|
4
|
-
import
|
|
3
|
+
import { resource, useResource, withKey } from "@assistant-ui/tap";
|
|
4
|
+
import { memo } from "react";
|
|
5
|
+
import type {
|
|
6
|
+
ToolCallMessagePartComponent,
|
|
7
|
+
ToolCallMessagePartProps,
|
|
8
|
+
} from "@assistant-ui/core/react";
|
|
5
9
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
McpAppBridgeHandlers,
|
|
12
|
+
McpAppsHost,
|
|
13
|
+
McpAppsRemoteHostOptions,
|
|
14
|
+
} from "./types";
|
|
7
15
|
|
|
8
16
|
const { framePropsMock } = vi.hoisted(() => ({ framePropsMock: vi.fn() }));
|
|
9
17
|
|
|
@@ -22,10 +30,19 @@ vi.mock("./app-frame", () => ({
|
|
|
22
30
|
}));
|
|
23
31
|
|
|
24
32
|
import { McpAppRenderer } from "./McpAppRenderer";
|
|
33
|
+
import { McpAppsRemoteHost } from "./McpAppsRemoteHost";
|
|
25
34
|
|
|
26
35
|
const useHost = ({ host }: { host: McpAppsHost }) => host;
|
|
27
36
|
const Host = resource(useHost);
|
|
28
37
|
|
|
38
|
+
const createDeferred = <T,>() => {
|
|
39
|
+
let resolve!: (value: T) => void;
|
|
40
|
+
const promise = new Promise<T>((res) => {
|
|
41
|
+
resolve = res;
|
|
42
|
+
});
|
|
43
|
+
return { promise, resolve };
|
|
44
|
+
};
|
|
45
|
+
|
|
29
46
|
const createPart = (serverId?: string): ToolCallMessagePartProps => ({
|
|
30
47
|
type: "tool-call",
|
|
31
48
|
toolCallId: "call-1",
|
|
@@ -54,6 +71,43 @@ function Harness({ host, serverId }: { host: McpAppsHost; serverId?: string }) {
|
|
|
54
71
|
return <Renderer {...createPart(serverId)} />;
|
|
55
72
|
}
|
|
56
73
|
|
|
74
|
+
const MemoizedPart = memo(function MemoizedPart({
|
|
75
|
+
Renderer,
|
|
76
|
+
}: {
|
|
77
|
+
Renderer: ToolCallMessagePartComponent;
|
|
78
|
+
}) {
|
|
79
|
+
return <Renderer {...createPart()} />;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function MemoizedHarness({ host }: { host: McpAppsHost }) {
|
|
83
|
+
const renderer = useResource(
|
|
84
|
+
McpAppRenderer({
|
|
85
|
+
host: Host({ host }),
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
return <MemoizedPart Renderer={renderer.render} />;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function RemoteHarness({
|
|
92
|
+
url,
|
|
93
|
+
headers,
|
|
94
|
+
fetch,
|
|
95
|
+
resourceKey,
|
|
96
|
+
}: {
|
|
97
|
+
url: string;
|
|
98
|
+
headers: NonNullable<McpAppsRemoteHostOptions["headers"]>;
|
|
99
|
+
fetch: typeof globalThis.fetch;
|
|
100
|
+
resourceKey?: string | number;
|
|
101
|
+
}) {
|
|
102
|
+
const host = McpAppsRemoteHost({ url, headers, fetch });
|
|
103
|
+
const renderer = useResource(
|
|
104
|
+
McpAppRenderer({
|
|
105
|
+
host: resourceKey === undefined ? host : withKey(resourceKey, host),
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
return <MemoizedPart Renderer={renderer.render} />;
|
|
109
|
+
}
|
|
110
|
+
|
|
57
111
|
describe("McpAppRenderer", () => {
|
|
58
112
|
beforeEach(() => {
|
|
59
113
|
framePropsMock.mockReset();
|
|
@@ -88,6 +142,211 @@ describe("McpAppRenderer", () => {
|
|
|
88
142
|
});
|
|
89
143
|
});
|
|
90
144
|
|
|
145
|
+
it("reloads the resource and hides stale HTML when the host changes", async () => {
|
|
146
|
+
const nextResource = createDeferred<{
|
|
147
|
+
uri: string;
|
|
148
|
+
mimeType: "text/html;profile=mcp-app";
|
|
149
|
+
html: string;
|
|
150
|
+
}>();
|
|
151
|
+
const firstHost: McpAppsHost = {
|
|
152
|
+
loadResource: vi.fn(async ({ uri }) => ({
|
|
153
|
+
uri,
|
|
154
|
+
mimeType: "text/html;profile=mcp-app" as const,
|
|
155
|
+
html: "first host",
|
|
156
|
+
})),
|
|
157
|
+
callTool: vi.fn(),
|
|
158
|
+
readResource: vi.fn(),
|
|
159
|
+
listResources: vi.fn(),
|
|
160
|
+
};
|
|
161
|
+
const nextHost: McpAppsHost = {
|
|
162
|
+
loadResource: vi.fn(() => nextResource.promise),
|
|
163
|
+
callTool: vi.fn(),
|
|
164
|
+
readResource: vi.fn(),
|
|
165
|
+
listResources: vi.fn(),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const view = render(<MemoizedHarness host={firstHost} />);
|
|
169
|
+
await waitFor(() =>
|
|
170
|
+
expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
|
|
171
|
+
"first host",
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
framePropsMock.mockClear();
|
|
176
|
+
view.rerender(<MemoizedHarness host={nextHost} />);
|
|
177
|
+
|
|
178
|
+
expect(nextHost.loadResource).toHaveBeenCalledTimes(1);
|
|
179
|
+
expect(framePropsMock).not.toHaveBeenCalled();
|
|
180
|
+
|
|
181
|
+
nextResource.resolve({
|
|
182
|
+
uri: "ui://example/search",
|
|
183
|
+
mimeType: "text/html;profile=mcp-app",
|
|
184
|
+
html: "next host",
|
|
185
|
+
});
|
|
186
|
+
await waitFor(() =>
|
|
187
|
+
expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe("next host"),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reloads remote resources when the URL changes and keeps headers current", async () => {
|
|
192
|
+
const fetch = vi.fn(
|
|
193
|
+
async (url: string | URL | Request, init?: RequestInit) =>
|
|
194
|
+
Response.json({
|
|
195
|
+
uri: "ui://example/search",
|
|
196
|
+
mimeType: "text/html;profile=mcp-app",
|
|
197
|
+
html: `${String(url)}:${new Headers(init?.headers).get("authorization")}`,
|
|
198
|
+
}),
|
|
199
|
+
) as unknown as typeof globalThis.fetch;
|
|
200
|
+
|
|
201
|
+
const view = render(
|
|
202
|
+
<RemoteHarness
|
|
203
|
+
url="/host-a"
|
|
204
|
+
headers={{ authorization: "Bearer a" }}
|
|
205
|
+
fetch={fetch}
|
|
206
|
+
/>,
|
|
207
|
+
);
|
|
208
|
+
await waitFor(() =>
|
|
209
|
+
expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
|
|
210
|
+
"/host-a:Bearer a",
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
view.rerender(
|
|
215
|
+
<RemoteHarness
|
|
216
|
+
url="/host-b"
|
|
217
|
+
headers={{ authorization: "Bearer a" }}
|
|
218
|
+
fetch={fetch}
|
|
219
|
+
/>,
|
|
220
|
+
);
|
|
221
|
+
await waitFor(() =>
|
|
222
|
+
expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
|
|
223
|
+
"/host-b:Bearer a",
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
view.rerender(
|
|
228
|
+
<RemoteHarness
|
|
229
|
+
url="/host-b"
|
|
230
|
+
headers={{ authorization: "Bearer b" }}
|
|
231
|
+
fetch={fetch}
|
|
232
|
+
/>,
|
|
233
|
+
);
|
|
234
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
235
|
+
await framePropsMock.mock.lastCall?.[0].handlers.callTool({
|
|
236
|
+
name: "search",
|
|
237
|
+
});
|
|
238
|
+
expect(fetch).toHaveBeenNthCalledWith(
|
|
239
|
+
3,
|
|
240
|
+
"/host-b",
|
|
241
|
+
expect.objectContaining({
|
|
242
|
+
headers: {
|
|
243
|
+
"content-type": "application/json",
|
|
244
|
+
authorization: "Bearer b",
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
view.rerender(
|
|
250
|
+
<RemoteHarness
|
|
251
|
+
url="/host-b"
|
|
252
|
+
headers={{ authorization: "Bearer b" }}
|
|
253
|
+
fetch={fetch}
|
|
254
|
+
/>,
|
|
255
|
+
);
|
|
256
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("keeps mutated static headers current without reloading the resource", async () => {
|
|
260
|
+
const fetch = vi.fn(
|
|
261
|
+
async (url: string | URL | Request, init?: RequestInit) =>
|
|
262
|
+
Response.json({
|
|
263
|
+
uri: "ui://example/search",
|
|
264
|
+
mimeType: "text/html;profile=mcp-app",
|
|
265
|
+
html: `${String(url)}:${new Headers(init?.headers).get("authorization")}`,
|
|
266
|
+
}),
|
|
267
|
+
) as unknown as typeof globalThis.fetch;
|
|
268
|
+
const headers = { authorization: "Bearer a" };
|
|
269
|
+
|
|
270
|
+
const view = render(
|
|
271
|
+
<RemoteHarness url="/host" headers={headers} fetch={fetch} />,
|
|
272
|
+
);
|
|
273
|
+
await waitFor(() =>
|
|
274
|
+
expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
|
|
275
|
+
"/host:Bearer a",
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
headers.authorization = "Bearer b";
|
|
280
|
+
view.rerender(
|
|
281
|
+
<RemoteHarness url="/host" headers={headers} fetch={fetch} />,
|
|
282
|
+
);
|
|
283
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
284
|
+
await framePropsMock.mock.lastCall?.[0].handlers.callTool({
|
|
285
|
+
name: "search",
|
|
286
|
+
});
|
|
287
|
+
expect(fetch).toHaveBeenNthCalledWith(
|
|
288
|
+
2,
|
|
289
|
+
"/host",
|
|
290
|
+
expect.objectContaining({
|
|
291
|
+
headers: {
|
|
292
|
+
"content-type": "application/json",
|
|
293
|
+
authorization: "Bearer b",
|
|
294
|
+
},
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("uses resource keys to reload dynamic headers without callback identity churn", async () => {
|
|
301
|
+
const fetch = vi.fn(
|
|
302
|
+
async (url: string | URL | Request, init?: RequestInit) =>
|
|
303
|
+
Response.json({
|
|
304
|
+
uri: "ui://example/search",
|
|
305
|
+
mimeType: "text/html;profile=mcp-app",
|
|
306
|
+
html: `${String(url)}:${new Headers(init?.headers).get("authorization")}`,
|
|
307
|
+
}),
|
|
308
|
+
) as unknown as typeof globalThis.fetch;
|
|
309
|
+
|
|
310
|
+
const view = render(
|
|
311
|
+
<RemoteHarness
|
|
312
|
+
url="/host"
|
|
313
|
+
headers={() => ({ authorization: "Bearer a" })}
|
|
314
|
+
resourceKey="workspace-a"
|
|
315
|
+
fetch={fetch}
|
|
316
|
+
/>,
|
|
317
|
+
);
|
|
318
|
+
await waitFor(() =>
|
|
319
|
+
expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
|
|
320
|
+
"/host:Bearer a",
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
view.rerender(
|
|
325
|
+
<RemoteHarness
|
|
326
|
+
url="/host"
|
|
327
|
+
headers={() => ({ authorization: "Bearer a" })}
|
|
328
|
+
resourceKey="workspace-a"
|
|
329
|
+
fetch={fetch}
|
|
330
|
+
/>,
|
|
331
|
+
);
|
|
332
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
333
|
+
|
|
334
|
+
view.rerender(
|
|
335
|
+
<RemoteHarness
|
|
336
|
+
url="/host"
|
|
337
|
+
headers={() => ({ authorization: "Bearer b" })}
|
|
338
|
+
resourceKey="workspace-b"
|
|
339
|
+
fetch={fetch}
|
|
340
|
+
/>,
|
|
341
|
+
);
|
|
342
|
+
await waitFor(() =>
|
|
343
|
+
expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
|
|
344
|
+
"/host:Bearer b",
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
348
|
+
});
|
|
349
|
+
|
|
91
350
|
it("gives the renderer serverId precedence in listResources", async () => {
|
|
92
351
|
const listResources = vi.fn();
|
|
93
352
|
const host: McpAppsHost = {
|