@assistant-ui/react 0.15.2 → 0.15.4

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 (50) hide show
  1. package/dist/client/ExternalThread.d.ts.map +1 -1
  2. package/dist/client/ExternalThread.js +1 -0
  3. package/dist/client/ExternalThread.js.map +1 -1
  4. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  5. package/dist/client/InMemoryThreadList.js +199 -176
  6. package/dist/client/InMemoryThreadList.js.map +1 -1
  7. package/dist/client/SingleThreadList.d.ts.map +1 -1
  8. package/dist/client/SingleThreadList.js +56 -48
  9. package/dist/client/SingleThreadList.js.map +1 -1
  10. package/dist/legacy-runtime/cloud/auiV0.d.ts +2 -0
  11. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  12. package/dist/legacy-runtime/cloud/auiV0.js +6 -4
  13. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  14. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +6 -0
  15. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +1 -1
  16. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  17. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +20 -15
  18. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  19. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  20. package/dist/mcp-apps/McpAppRenderer.js +25 -14
  21. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  22. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
  23. package/dist/mcp-apps/McpAppsRemoteHost.js +17 -6
  24. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
  25. package/dist/primitives/message/MessagePartsGrouped.d.ts +6 -1
  26. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  27. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  28. package/dist/primitives/suggestion/SuggestionTrigger.d.ts +4 -2
  29. package/dist/primitives/suggestion/SuggestionTrigger.d.ts.map +1 -1
  30. package/dist/primitives/suggestion/SuggestionTrigger.js +21 -42
  31. package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
  32. package/dist/primitives/thread/ThreadSuggestion.d.ts +4 -2
  33. package/dist/primitives/thread/ThreadSuggestion.d.ts.map +1 -1
  34. package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
  35. package/package.json +6 -6
  36. package/src/client/ExternalThread.ts +1 -0
  37. package/src/client/InMemoryThreadList.ts +27 -7
  38. package/src/client/SingleThreadList.ts +10 -2
  39. package/src/legacy-runtime/cloud/auiV0.ts +8 -2
  40. package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +6 -0
  41. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +31 -0
  42. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +26 -17
  43. package/src/mcp-apps/McpAppRenderer.test.tsx +262 -3
  44. package/src/mcp-apps/McpAppRenderer.tsx +27 -15
  45. package/src/mcp-apps/McpAppsRemoteHost.ts +18 -9
  46. package/src/primitives/message/MessagePartsGrouped.tsx +6 -1
  47. package/src/primitives/suggestion/SuggestionTrigger.ts +11 -32
  48. package/src/primitives/thread/ThreadSuggestion.ts +2 -1
  49. package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +115 -0
  50. package/src/tests/threadListItemIsRunning.test.tsx +198 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
4
4
  "description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,8 +55,8 @@
55
55
  ],
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "@assistant-ui/core": "^0.3.3",
59
- "@assistant-ui/store": "^0.3.1",
58
+ "@assistant-ui/core": "^0.3.5",
59
+ "@assistant-ui/store": "^0.3.3",
60
60
  "@assistant-ui/tap": "^0.9.9",
61
61
  "@radix-ui/primitive": "^1.1.7",
62
62
  "@radix-ui/react-collection": "^1.1.15",
@@ -66,12 +66,12 @@
66
66
  "@radix-ui/react-use-callback-ref": "^1.1.4",
67
67
  "@radix-ui/react-use-controllable-state": "^1.2.6",
68
68
  "@radix-ui/react-use-escape-keydown": "^1.1.5",
69
- "assistant-cloud": "^0.1.37",
70
- "assistant-stream": "^0.3.30",
69
+ "assistant-cloud": "^0.1.38",
70
+ "assistant-stream": "^0.3.33",
71
71
  "nanoid": "^6.0.0",
72
72
  "radix-ui": "^1.6.7",
73
73
  "react-textarea-autosize": "^8.5.9",
74
- "safe-content-frame": "^0.0.25",
74
+ "safe-content-frame": "^0.0.26",
75
75
  "zod": "^4.4.3",
76
76
  "zustand": "^5.0.14"
77
77
  },
@@ -835,6 +835,7 @@ const useExternalThread = ({
835
835
  edit: hasEdit,
836
836
  delete: false,
837
837
  reload: hasReload,
838
+ refetchThread: false,
838
839
  cancel: isRunning,
839
840
  speech: false,
840
841
  attachments: hasAttachments,
@@ -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 { data, onSwitchTo, onUpdateCustom, onArchive, onUnarchive, onDelete } =
39
- props;
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 = (): ClientOutput<"threadListItem"> => {
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 (
@@ -77,6 +77,7 @@ type AuiV0MessagePart =
77
77
  readonly data: string;
78
78
  readonly mimeType: string;
79
79
  readonly filename?: string;
80
+ readonly sourceType?: "url" | "id";
80
81
  };
81
82
 
82
83
  type AuiV0AttachmentPart =
@@ -94,6 +95,7 @@ type AuiV0AttachmentPart =
94
95
  readonly data: string;
95
96
  readonly mimeType: string;
96
97
  readonly filename?: string;
98
+ readonly sourceType?: "url" | "id";
97
99
  }
98
100
  | {
99
101
  readonly type: "audio";
@@ -157,6 +159,9 @@ const encodeAttachmentPart = (
157
159
  data: part.data,
158
160
  mimeType: part.mimeType,
159
161
  ...(part.filename != null ? { filename: part.filename } : undefined),
162
+ ...(part.sourceType != null
163
+ ? { sourceType: part.sourceType }
164
+ : undefined),
160
165
  };
161
166
 
162
167
  case "audio":
@@ -252,7 +257,7 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
252
257
  };
253
258
 
254
259
  case "tool-call": {
255
- if (!isJSONValue(part.result)) {
260
+ if (part.result !== undefined && !isJSONValue(part.result)) {
256
261
  console.warn(
257
262
  `tool-call result is not JSON! ${JSON.stringify(part)}`,
258
263
  );
@@ -264,7 +269,7 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
264
269
  ...(JSON.stringify(part.args) === part.argsText
265
270
  ? { args: part.args }
266
271
  : { argsText: part.argsText }),
267
- ...(part.result
272
+ ...(part.result !== undefined
268
273
  ? { result: part.result as ReadonlyJSONValue }
269
274
  : undefined),
270
275
  ...(part.isError ? { isError: true } : undefined),
@@ -281,6 +286,7 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
281
286
  data: part.data,
282
287
  mimeType: part.mimeType,
283
288
  ...(part.filename ? { filename: part.filename } : undefined),
289
+ ...(part.sourceType ? { sourceType: part.sourceType } : undefined),
284
290
  };
285
291
 
286
292
  default: {
@@ -110,6 +110,12 @@ export type AssistantTransportOptions<T> = {
110
110
  api: string;
111
111
  resumeApi?: string;
112
112
  protocol?: AssistantTransportProtocol;
113
+ /**
114
+ * When `false`, stream decoding and state reconciliation tolerate malformed
115
+ * input (invalid chunks are dropped with a console log) instead of throwing.
116
+ * Resume runs always decode leniently. Defaults to `true`.
117
+ */
118
+ strict?: boolean;
113
119
  converter: AssistantTransportStateConverter<T>;
114
120
  headers: HeadersValue | (() => Promise<HeadersValue>);
115
121
  body?: object | (() => Promise<object | undefined>);
@@ -112,6 +112,7 @@ const mountRuntime = (
112
112
 
113
113
  afterEach(() => {
114
114
  vi.unstubAllGlobals();
115
+ vi.restoreAllMocks();
115
116
  });
116
117
 
117
118
  describe("useAssistantTransportRuntime", () => {
@@ -145,6 +146,36 @@ describe("useAssistantTransportRuntime", () => {
145
146
  expect(fetchMock.requests).toHaveLength(1);
146
147
  });
147
148
 
149
+ it("skips add-message commands with no supported parts", async () => {
150
+ const fetchMock = installFetch();
151
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
152
+ const { aui, sendCommand } = mountRuntime();
153
+ await waitFor(() =>
154
+ expect(
155
+ (aui().thread.getState().extras as { sendCommand?: unknown })
156
+ ?.sendCommand,
157
+ ).toBeTypeOf("function"),
158
+ );
159
+
160
+ act(() =>
161
+ aui().thread.append({
162
+ role: "user",
163
+ content: [{ type: "audio", audio: { data: "", format: "mp3" } }],
164
+ }),
165
+ );
166
+
167
+ await act(async () => {});
168
+ expect(warn).toHaveBeenCalledWith(
169
+ "[assistant-ui] Skipped add-message command with no supported parts",
170
+ );
171
+ expect(fetchMock.requests).toHaveLength(0);
172
+
173
+ // The skipped message must not leak its parentId into later batches.
174
+ act(() => sendCommand(createMessageCommand("follow-up")));
175
+ await waitFor(() => expect(fetchMock.requests).toHaveLength(1));
176
+ expect(fetchMock.requests[0]!.body).not.toHaveProperty("parentId");
177
+ });
178
+
148
179
  it("flushes commands enqueued during a resume run in a follow-up run", async () => {
149
180
  const fetchMock = installFetch();
150
181
  const { aui, sendCommand } = mountRuntime({
@@ -42,7 +42,7 @@ import type { UserExternalState } from "../../../augmentations";
42
42
 
43
43
  const convertAppendMessageToCommand = (
44
44
  message: AppendMessage,
45
- ): AddMessageCommand => {
45
+ ): AddMessageCommand | null => {
46
46
  if (message.role !== "user")
47
47
  throw new Error("Only user messages are supported");
48
48
 
@@ -59,6 +59,8 @@ const convertAppendMessageToCommand = (
59
59
  }
60
60
  }
61
61
 
62
+ if (parts.length === 0) return null;
63
+
62
64
  return {
63
65
  type: "add-message",
64
66
  message: {
@@ -127,6 +129,20 @@ const useAssistantTransportThreadRuntime = <T>(
127
129
  onQueue: () => runManager.schedule(),
128
130
  });
129
131
 
132
+ const enqueueAppendMessage = (message: AppendMessage) => {
133
+ const command = convertAppendMessageToCommand(message);
134
+ if (!command) {
135
+ console.warn(
136
+ "[assistant-ui] Skipped add-message command with no supported parts",
137
+ );
138
+ return;
139
+ }
140
+ parentIdRef.current = message.parentId;
141
+ commandQueue.enqueue(command, {
142
+ schedule: message.startRun ?? message.role === "user",
143
+ });
144
+ };
145
+
130
146
  const threadId = useAuiState((s) => s.threadListItem.remoteId);
131
147
 
132
148
  const runManager = useRunManager({
@@ -201,10 +217,12 @@ const useAssistantTransportThreadRuntime = <T>(
201
217
 
202
218
  // Select decoder based on protocol option
203
219
  const protocol = options.protocol ?? "data-stream";
220
+ // Resume replays a best-effort buffer; always reconcile leniently.
221
+ const strict = isResume ? false : (options.strict ?? true);
204
222
  const decoder =
205
223
  protocol === "assistant-transport"
206
- ? new AssistantTransportDecoder()
207
- : new DataStreamDecoder();
224
+ ? new AssistantTransportDecoder({ strict })
225
+ : new DataStreamDecoder({ strict });
208
226
 
209
227
  let err: string | undefined;
210
228
  const stream = body.pipeThrough(decoder).pipeThrough(
@@ -214,6 +232,7 @@ const useAssistantTransportThreadRuntime = <T>(
214
232
  (agentStateRef.current as ReadonlyJSONValue) ?? null,
215
233
  }),
216
234
  throttle: isResume,
235
+ strict,
217
236
  onError: (error) => {
218
237
  err = error;
219
238
  },
@@ -329,21 +348,11 @@ const useAssistantTransportThreadRuntime = <T>(
329
348
  },
330
349
  state: agentStateRef.current as UserExternalState,
331
350
  } satisfies AssistantTransportExtras,
332
- onNew: async (message: AppendMessage): Promise<void> => {
333
- parentIdRef.current = message.parentId;
334
- const command = convertAppendMessageToCommand(message);
335
- commandQueue.enqueue(command, {
336
- schedule: message.startRun ?? message.role === "user",
337
- });
338
- },
351
+ onNew: async (message: AppendMessage): Promise<void> =>
352
+ enqueueAppendMessage(message),
339
353
  ...(options.capabilities?.edit && {
340
- onEdit: async (message: AppendMessage): Promise<void> => {
341
- parentIdRef.current = message.parentId;
342
- const command = convertAppendMessageToCommand(message);
343
- commandQueue.enqueue(command, {
344
- schedule: message.startRun ?? message.role === "user",
345
- });
346
- },
354
+ onEdit: async (message: AppendMessage): Promise<void> =>
355
+ enqueueAppendMessage(message),
347
356
  }),
348
357
  ...(commandQueue.state.queued.length > 0 && {
349
358
  onReload: async (parentId: string | null) => {
@@ -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 type { ToolCallMessagePartProps } from "@assistant-ui/core/react";
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 { McpAppBridgeHandlers, McpAppsHost } from "./types";
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 = {