@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.
- package/dist/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +1 -0
- 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/legacy-runtime/cloud/auiV0.d.ts +2 -0
- package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.js +6 -4
- package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +6 -0
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +20 -15
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.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/package.json +6 -6
- package/src/client/ExternalThread.ts +1 -0
- package/src/client/InMemoryThreadList.ts +27 -7
- package/src/client/SingleThreadList.ts +10 -2
- package/src/legacy-runtime/cloud/auiV0.ts +8 -2
- package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +6 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +31 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +26 -17
- 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/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.
|
|
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.
|
|
59
|
-
"@assistant-ui/store": "^0.3.
|
|
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.
|
|
70
|
-
"assistant-stream": "^0.3.
|
|
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.
|
|
74
|
+
"safe-content-frame": "^0.0.26",
|
|
75
75
|
"zod": "^4.4.3",
|
|
76
76
|
"zustand": "^5.0.14"
|
|
77
77
|
},
|
|
@@ -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 (
|
|
@@ -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>);
|
package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx
CHANGED
|
@@ -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({
|
package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|