@assistant-ui/react 0.15.18 → 0.15.19
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/README.md +7 -1
- package/dist/context/providers/ThreadViewportProvider.js +2 -2
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +17 -12
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +7 -1
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +3 -8
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +127 -137
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/message/MessageParts.js +2 -14
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
- package/dist/primitives/message/MessagePartsGrouped.js +2 -2
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +3 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js +13 -36
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/reasoning/useScrollLock.js +3 -1
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +22 -4
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +24 -2
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.js +2 -2
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -5
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +16 -15
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/webmcp/convertTools.d.ts.map +1 -1
- package/dist/unstable/webmcp/convertTools.js +54 -3
- package/dist/unstable/webmcp/convertTools.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +2 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/Primitive.d.ts +14 -6
- package/dist/utils/Primitive.d.ts.map +1 -1
- package/dist/utils/Primitive.js +25 -6
- package/dist/utils/Primitive.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +17 -3
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/package.json +11 -11
- package/src/context/providers/ThreadViewportProvider.tsx +2 -2
- package/src/hooks/useToolCallElapsed.ts +3 -0
- package/src/index.ts +1 -1
- package/src/mcp-apps/app-frame.test.tsx +189 -0
- package/src/mcp-apps/app-frame.tsx +31 -22
- package/src/mcp-apps/bridge.test.ts +98 -0
- package/src/mcp-apps/bridge.ts +7 -1
- package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
- package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
- package/src/primitives/composer/ComposerInput.test.tsx +22 -0
- package/src/primitives/composer/ComposerInput.tsx +5 -9
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +5 -3
- package/src/primitives/message/MessageParts.tsx +1 -21
- package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
- package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
- package/src/primitives/messagePart/MessagePartText.tsx +22 -9
- package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
- package/src/primitives/reasoning/useScrollLock.ts +15 -1
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
- package/src/sandbox-host/SandboxHost.test.tsx +40 -0
- package/src/sandbox-host/SandboxHost.tsx +24 -4
- package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
- package/src/tests/toolCallTiming.test.tsx +55 -1
- package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
- package/src/unstable/useLiveCompletionAdapter.ts +38 -8
- package/src/unstable/useMentionAdapter.test.tsx +47 -1
- package/src/unstable/useMentionAdapter.ts +2 -2
- package/src/unstable/useMessageStallDetection.ts +26 -19
- package/src/unstable/webmcp/convertTools.test.ts +181 -21
- package/src/unstable/webmcp/convertTools.ts +87 -7
- package/src/unstable/webmcp/useWebMcpProvider.ts +2 -4
- package/src/utils/Primitive.test.tsx +46 -1
- package/src/utils/Primitive.tsx +37 -7
- package/src/utils/getSelectionMessageId.test.ts +176 -1
- package/src/utils/getSelectionMessageId.ts +21 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @vitest-environment jsdom */
|
|
2
|
-
import { startTransition, Suspense } from "react";
|
|
2
|
+
import { startTransition, Suspense, useLayoutEffect } from "react";
|
|
3
3
|
import { act, render, renderHook } from "@testing-library/react";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { Unstable_TriggerItem } from "@assistant-ui/core";
|
|
@@ -62,6 +62,159 @@ describe("unstable_useLiveCompletionAdapter", () => {
|
|
|
62
62
|
expect(fetcher).not.toHaveBeenCalled();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
it("does not start a queued fetch after unmount", async () => {
|
|
66
|
+
const fetcher = vi.fn(async () => []);
|
|
67
|
+
const { result, unmount } = renderHook(() =>
|
|
68
|
+
unstable_useLiveCompletionAdapter({ fetcher, debounceMs: 0 }),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
result.current.adapter.search!("alice");
|
|
72
|
+
unmount();
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
|
|
75
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
76
|
+
await vi.runAllTimersAsync();
|
|
77
|
+
|
|
78
|
+
expect(fetcher).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps an in-flight fetch when the debounce changes", async () => {
|
|
82
|
+
let resolve!: (items: readonly Unstable_TriggerItem[]) => void;
|
|
83
|
+
const fetcher = vi.fn(
|
|
84
|
+
() =>
|
|
85
|
+
new Promise<readonly Unstable_TriggerItem[]>((r) => {
|
|
86
|
+
resolve = r;
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
const { result, rerender } = renderHook(
|
|
90
|
+
({ debounceMs }) =>
|
|
91
|
+
unstable_useLiveCompletionAdapter({ fetcher, debounceMs }),
|
|
92
|
+
{ initialProps: { debounceMs: 0 } },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await act(async () => {
|
|
96
|
+
result.current.adapter.search!("alice");
|
|
97
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
98
|
+
});
|
|
99
|
+
expect(fetcher).toHaveBeenCalledOnce();
|
|
100
|
+
|
|
101
|
+
rerender({ debounceMs: 50 });
|
|
102
|
+
await act(async () => resolve([item("alice")]));
|
|
103
|
+
|
|
104
|
+
expect(result.current.adapter.search!("alice")).toEqual([item("alice")]);
|
|
105
|
+
expect(result.current.isLoading).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("replays a search queued while the adapter is hidden", async () => {
|
|
109
|
+
const fetcher = vi.fn(async () => []);
|
|
110
|
+
const suspended = new Promise<never>(() => {});
|
|
111
|
+
let committed!: ReturnType<typeof unstable_useLiveCompletionAdapter>;
|
|
112
|
+
const Harness = ({ blocked }: { blocked: boolean }) => {
|
|
113
|
+
const current = unstable_useLiveCompletionAdapter({
|
|
114
|
+
fetcher,
|
|
115
|
+
debounceMs: 0,
|
|
116
|
+
});
|
|
117
|
+
useLayoutEffect(() => {
|
|
118
|
+
committed = current;
|
|
119
|
+
}, [current]);
|
|
120
|
+
if (blocked) throw suspended;
|
|
121
|
+
return null;
|
|
122
|
+
};
|
|
123
|
+
const view = (blocked: boolean) => (
|
|
124
|
+
<Suspense fallback={null}>
|
|
125
|
+
<Harness blocked={blocked} />
|
|
126
|
+
</Suspense>
|
|
127
|
+
);
|
|
128
|
+
const rendered = render(view(false));
|
|
129
|
+
|
|
130
|
+
await act(async () => rendered.rerender(view(true)));
|
|
131
|
+
committed.adapter.search!("alice");
|
|
132
|
+
await Promise.resolve();
|
|
133
|
+
|
|
134
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
135
|
+
await act(async () => rendered.rerender(view(false)));
|
|
136
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
137
|
+
|
|
138
|
+
expect(fetcher).toHaveBeenCalledOnce();
|
|
139
|
+
expect(fetcher).toHaveBeenCalledWith("alice");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("drops a hidden search superseded by a cached query", async () => {
|
|
143
|
+
const fetcher = vi.fn(async (query: string) => [item(query)]);
|
|
144
|
+
const suspended = new Promise<never>(() => {});
|
|
145
|
+
let committed!: ReturnType<typeof unstable_useLiveCompletionAdapter>;
|
|
146
|
+
const Harness = ({ blocked }: { blocked: boolean }) => {
|
|
147
|
+
const current = unstable_useLiveCompletionAdapter({
|
|
148
|
+
fetcher,
|
|
149
|
+
debounceMs: 0,
|
|
150
|
+
});
|
|
151
|
+
useLayoutEffect(() => {
|
|
152
|
+
committed = current;
|
|
153
|
+
}, [current]);
|
|
154
|
+
if (blocked) throw suspended;
|
|
155
|
+
return null;
|
|
156
|
+
};
|
|
157
|
+
const view = (blocked: boolean) => (
|
|
158
|
+
<Suspense fallback={null}>
|
|
159
|
+
<Harness blocked={blocked} />
|
|
160
|
+
</Suspense>
|
|
161
|
+
);
|
|
162
|
+
const rendered = render(view(false));
|
|
163
|
+
|
|
164
|
+
await act(async () => {
|
|
165
|
+
committed.adapter.search!("alice");
|
|
166
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
167
|
+
});
|
|
168
|
+
expect(committed.adapter.search!("alice")).toEqual([item("alice")]);
|
|
169
|
+
|
|
170
|
+
await act(async () => rendered.rerender(view(true)));
|
|
171
|
+
committed.adapter.search!("bob");
|
|
172
|
+
await Promise.resolve();
|
|
173
|
+
committed.adapter.search!("alice");
|
|
174
|
+
await Promise.resolve();
|
|
175
|
+
|
|
176
|
+
await act(async () => rendered.rerender(view(false)));
|
|
177
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
178
|
+
|
|
179
|
+
expect(fetcher).toHaveBeenCalledOnce();
|
|
180
|
+
expect(fetcher).toHaveBeenCalledWith("alice");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("settles loading when a suspended adapter is shown again", async () => {
|
|
184
|
+
const fetcher = vi.fn(() => new Promise<never>(() => {}));
|
|
185
|
+
const suspended = new Promise<never>(() => {});
|
|
186
|
+
let committed!: ReturnType<typeof unstable_useLiveCompletionAdapter>;
|
|
187
|
+
const Harness = ({ blocked }: { blocked: boolean }) => {
|
|
188
|
+
const current = unstable_useLiveCompletionAdapter({
|
|
189
|
+
fetcher,
|
|
190
|
+
debounceMs: 0,
|
|
191
|
+
});
|
|
192
|
+
useLayoutEffect(() => {
|
|
193
|
+
committed = current;
|
|
194
|
+
}, [current]);
|
|
195
|
+
if (blocked) throw suspended;
|
|
196
|
+
return <output data-testid="status">{String(current.isLoading)}</output>;
|
|
197
|
+
};
|
|
198
|
+
const view = (blocked: boolean) => (
|
|
199
|
+
<Suspense fallback={null}>
|
|
200
|
+
<Harness blocked={blocked} />
|
|
201
|
+
</Suspense>
|
|
202
|
+
);
|
|
203
|
+
const rendered = render(view(false));
|
|
204
|
+
|
|
205
|
+
await act(async () => {
|
|
206
|
+
committed.adapter.search!("alice");
|
|
207
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
208
|
+
});
|
|
209
|
+
expect(fetcher).toHaveBeenCalledOnce();
|
|
210
|
+
expect(rendered.getByTestId("status").textContent).toBe("true");
|
|
211
|
+
|
|
212
|
+
await act(async () => rendered.rerender(view(true)));
|
|
213
|
+
await act(async () => rendered.rerender(view(false)));
|
|
214
|
+
|
|
215
|
+
expect(rendered.getByTestId("status").textContent).toBe("false");
|
|
216
|
+
});
|
|
217
|
+
|
|
65
218
|
it("does not fetch when disabled and clears cached items", async () => {
|
|
66
219
|
const fetcher = vi.fn(async () => [item("a")]);
|
|
67
220
|
const { result, rerender } = renderHook(
|
|
@@ -435,6 +588,54 @@ describe("unstable_useLiveCompletionAdapter", () => {
|
|
|
435
588
|
expect(result.current.adapter.search!("alice")).toEqual([item("alice")]);
|
|
436
589
|
});
|
|
437
590
|
|
|
591
|
+
it("re-arms a failed query when its retry is hidden", async () => {
|
|
592
|
+
const fetcher = vi
|
|
593
|
+
.fn<(query: string) => Promise<readonly Unstable_TriggerItem[]>>()
|
|
594
|
+
.mockRejectedValueOnce(new Error("temporarily unavailable"))
|
|
595
|
+
.mockImplementationOnce(() => new Promise(() => {}))
|
|
596
|
+
.mockResolvedValueOnce([item("alice")]);
|
|
597
|
+
const suspended = new Promise<never>(() => {});
|
|
598
|
+
let committed!: ReturnType<typeof unstable_useLiveCompletionAdapter>;
|
|
599
|
+
const Harness = ({ blocked }: { blocked: boolean }) => {
|
|
600
|
+
const current = unstable_useLiveCompletionAdapter({
|
|
601
|
+
fetcher,
|
|
602
|
+
debounceMs: 0,
|
|
603
|
+
});
|
|
604
|
+
useLayoutEffect(() => {
|
|
605
|
+
committed = current;
|
|
606
|
+
}, [current]);
|
|
607
|
+
if (blocked) throw suspended;
|
|
608
|
+
return null;
|
|
609
|
+
};
|
|
610
|
+
const view = (blocked: boolean) => (
|
|
611
|
+
<Suspense fallback={null}>
|
|
612
|
+
<Harness blocked={blocked} />
|
|
613
|
+
</Suspense>
|
|
614
|
+
);
|
|
615
|
+
const rendered = render(view(false));
|
|
616
|
+
|
|
617
|
+
await act(async () => {
|
|
618
|
+
committed.adapter.search!("alice");
|
|
619
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
620
|
+
});
|
|
621
|
+
expect(fetcher).toHaveBeenCalledOnce();
|
|
622
|
+
|
|
623
|
+
await act(async () => {
|
|
624
|
+
committed.adapter.search!("alice");
|
|
625
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
626
|
+
});
|
|
627
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
628
|
+
|
|
629
|
+
await act(async () => rendered.rerender(view(true)));
|
|
630
|
+
await act(async () => rendered.rerender(view(false)));
|
|
631
|
+
await act(async () => {
|
|
632
|
+
committed.adapter.search!("alice");
|
|
633
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
expect(fetcher).toHaveBeenCalledTimes(3);
|
|
637
|
+
});
|
|
638
|
+
|
|
438
639
|
it("drops an in-flight fetch when the query returns to a cached value", async () => {
|
|
439
640
|
const resolvers: Record<
|
|
440
641
|
string,
|
|
@@ -84,6 +84,8 @@ export function unstable_useLiveCompletionAdapter(
|
|
|
84
84
|
const pendingQueryRef = useRef<string | null>(null);
|
|
85
85
|
const retryableQueryRef = useRef<string | null>(null);
|
|
86
86
|
const pendingRetryQueryRef = useRef<string | null>(null);
|
|
87
|
+
const inactiveRef = useRef(true);
|
|
88
|
+
const deferredQueryRef = useRef<string | null>(null);
|
|
87
89
|
|
|
88
90
|
const cancelTimer = useCallback(() => {
|
|
89
91
|
if (timerRef.current !== null) {
|
|
@@ -102,6 +104,10 @@ export function unstable_useLiveCompletionAdapter(
|
|
|
102
104
|
const scheduleFetch = useCallback(
|
|
103
105
|
(query: string) => {
|
|
104
106
|
if (!enabled) return;
|
|
107
|
+
if (inactiveRef.current) {
|
|
108
|
+
deferredQueryRef.current = query;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
105
111
|
if (pendingQueryRef.current === query) return;
|
|
106
112
|
rearmPendingRetry();
|
|
107
113
|
if (retryableQueryRef.current === query) {
|
|
@@ -136,6 +142,11 @@ export function unstable_useLiveCompletionAdapter(
|
|
|
136
142
|
[enabled, debounceMs, cancelTimer, rearmPendingRetry],
|
|
137
143
|
);
|
|
138
144
|
|
|
145
|
+
const scheduleFetchRef = useRef(scheduleFetch);
|
|
146
|
+
useLayoutEffect(() => {
|
|
147
|
+
scheduleFetchRef.current = scheduleFetch;
|
|
148
|
+
}, [scheduleFetch]);
|
|
149
|
+
|
|
139
150
|
const invalidatePending = useCallback(() => {
|
|
140
151
|
rearmPendingRetry();
|
|
141
152
|
cancelTimer();
|
|
@@ -162,7 +173,18 @@ export function unstable_useLiveCompletionAdapter(
|
|
|
162
173
|
);
|
|
163
174
|
}, [enabled, invalidatePending]);
|
|
164
175
|
|
|
165
|
-
|
|
176
|
+
// Render-time searches can outlive an abandoned render, so they only arm
|
|
177
|
+
// request work after this hook commits.
|
|
178
|
+
useLayoutEffect(() => {
|
|
179
|
+
inactiveRef.current = false;
|
|
180
|
+
const deferredQuery = deferredQueryRef.current;
|
|
181
|
+
deferredQueryRef.current = null;
|
|
182
|
+
if (deferredQuery !== null) scheduleFetchRef.current(deferredQuery);
|
|
183
|
+
return () => {
|
|
184
|
+
inactiveRef.current = true;
|
|
185
|
+
invalidatePending();
|
|
186
|
+
};
|
|
187
|
+
}, [invalidatePending]);
|
|
166
188
|
|
|
167
189
|
// Arm retries only after the failed state commits. Arming during rejection
|
|
168
190
|
// would let the failure render immediately schedule another request.
|
|
@@ -179,13 +201,21 @@ export function unstable_useLiveCompletionAdapter(
|
|
|
179
201
|
// queueMicrotask so they are not dispatched while another component renders.
|
|
180
202
|
if (query !== state.query || retryableQueryRef.current === query) {
|
|
181
203
|
queueMicrotask(() => scheduleFetch(query));
|
|
182
|
-
} else
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
204
|
+
} else {
|
|
205
|
+
queueMicrotask(() => {
|
|
206
|
+
if (
|
|
207
|
+
deferredQueryRef.current !== null &&
|
|
208
|
+
deferredQueryRef.current !== query
|
|
209
|
+
) {
|
|
210
|
+
deferredQueryRef.current = null;
|
|
211
|
+
}
|
|
212
|
+
if (
|
|
213
|
+
pendingQueryRef.current !== null &&
|
|
214
|
+
pendingQueryRef.current !== query
|
|
215
|
+
) {
|
|
216
|
+
invalidatePending();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
189
219
|
}
|
|
190
220
|
return state.items;
|
|
191
221
|
},
|
|
@@ -11,7 +11,10 @@ import type { Unstable_TriggerAdapter } from "@assistant-ui/core";
|
|
|
11
11
|
import { useState } from "react";
|
|
12
12
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
13
|
import { TriggerNavigationResource } from "../primitives/composer/trigger/triggerNavigationResource";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
unstable_useMentionAdapter,
|
|
16
|
+
type Unstable_UseMentionAdapterOptions,
|
|
17
|
+
} from "./useMentionAdapter";
|
|
15
18
|
|
|
16
19
|
const runtime = vi.hoisted(() => {
|
|
17
20
|
const state = {
|
|
@@ -66,6 +69,49 @@ describe("unstable_useMentionAdapter", () => {
|
|
|
66
69
|
runtime.events.length = 0;
|
|
67
70
|
});
|
|
68
71
|
|
|
72
|
+
it("ignores flat items when categories is explicitly empty", () => {
|
|
73
|
+
const { result } = renderHook(() =>
|
|
74
|
+
unstable_useMentionAdapter({
|
|
75
|
+
items: [{ id: "alice", type: "person", label: "Alice" }],
|
|
76
|
+
categories: [],
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(result.current.adapter.search?.("")).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not expose flat items when the last category is removed", () => {
|
|
84
|
+
const items = [{ id: "alice", type: "person", label: "Alice" }];
|
|
85
|
+
const { result, rerender } = renderHook(
|
|
86
|
+
(options: Unstable_UseMentionAdapterOptions) =>
|
|
87
|
+
unstable_useMentionAdapter(options),
|
|
88
|
+
{
|
|
89
|
+
initialProps: {
|
|
90
|
+
items,
|
|
91
|
+
categories: [
|
|
92
|
+
{
|
|
93
|
+
id: "people",
|
|
94
|
+
label: "People",
|
|
95
|
+
items: [{ id: "bob", type: "person", label: "Bob" }],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(result.current.adapter.search?.("")).toEqual([
|
|
103
|
+
{ id: "bob", type: "person", label: "Bob" },
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
rerender({ items, categories: [] });
|
|
107
|
+
expect(result.current.adapter.search?.("")).toEqual([]);
|
|
108
|
+
|
|
109
|
+
rerender({ items });
|
|
110
|
+
expect(result.current.adapter.search?.("")).toEqual([
|
|
111
|
+
{ id: "alice", type: "person", label: "Alice" },
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
69
115
|
it("keeps categorized model-context tools current", () => {
|
|
70
116
|
runtime.state.tools = {
|
|
71
117
|
searchDocs: { description: "Search documentation" },
|
|
@@ -95,7 +95,7 @@ const toolMentionSource: ModelContextSnapshotSource<
|
|
|
95
95
|
read: (aui) => {
|
|
96
96
|
const tools = aui.thread.getModelContext().tools;
|
|
97
97
|
if (!tools) return EMPTY_TOOL_MENTIONS;
|
|
98
|
-
const mentions
|
|
98
|
+
const mentions = Object.create(null) as Record<string, string | undefined>;
|
|
99
99
|
for (const [name, tool] of Object.entries(tools)) {
|
|
100
100
|
mentions[name] = tool.description;
|
|
101
101
|
}
|
|
@@ -152,7 +152,7 @@ export function unstable_useMentionAdapter(
|
|
|
152
152
|
const formatter = options?.formatter;
|
|
153
153
|
const onInserted = options?.onInserted;
|
|
154
154
|
const isCategorized =
|
|
155
|
-
|
|
155
|
+
categories !== undefined ||
|
|
156
156
|
(toolsConfig?.category !== undefined && items === undefined);
|
|
157
157
|
const toolMentions = useModelContextSnapshot(
|
|
158
158
|
aui,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { useAuiState } from "@assistant-ui/store";
|
|
5
|
+
import { useShallowSelector } from "@assistant-ui/store/internal";
|
|
5
6
|
|
|
6
7
|
export type Unstable_MessageStallDetectionOptions = {
|
|
7
8
|
/**
|
|
@@ -23,11 +24,10 @@ export type Unstable_MessageStallDetection = {
|
|
|
23
24
|
* @deprecated Under active development and might change without notice.
|
|
24
25
|
*
|
|
25
26
|
* Detects mid-run output stalls on the current message: while the message is
|
|
26
|
-
* running, watches
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* tokens have already streamed.
|
|
27
|
+
* running, watches its text, reasoning, and tool-argument values plus tool-result
|
|
28
|
+
* availability and reports a stall once they stop changing for `thresholdMs`.
|
|
29
|
+
* Useful for re-surfacing a "still working" indicator during tool think-time or
|
|
30
|
+
* provider stalls, after the first tokens have already streamed.
|
|
31
31
|
*
|
|
32
32
|
* Must be used inside a message scope.
|
|
33
33
|
*/
|
|
@@ -36,20 +36,27 @@ export function unstable_useMessageStallDetection(
|
|
|
36
36
|
): Unstable_MessageStallDetection {
|
|
37
37
|
const thresholdMs = options?.thresholdMs ?? 2000;
|
|
38
38
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
const activity = useAuiState(
|
|
40
|
+
useShallowSelector((s) => {
|
|
41
|
+
const running = s.message.status?.type === "running";
|
|
42
|
+
if (!running) return [false];
|
|
43
|
+
|
|
44
|
+
const values: unknown[] = [true, s.message.content.length];
|
|
45
|
+
|
|
46
|
+
for (const part of s.message.content) {
|
|
47
|
+
if (part.type === "text" || part.type === "reasoning") {
|
|
48
|
+
values.push(part.type, part.text);
|
|
49
|
+
} else if (part.type === "tool-call") {
|
|
50
|
+
values.push(part.type, part.argsText, part.result !== undefined);
|
|
51
|
+
} else {
|
|
52
|
+
values.push(part.type);
|
|
53
|
+
}
|
|
47
54
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
return values;
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
51
58
|
|
|
52
|
-
const running =
|
|
59
|
+
const running = activity[0] === true;
|
|
53
60
|
const lastActivityRef = useRef(Date.now());
|
|
54
61
|
const [stalled, setStalled] = useState(false);
|
|
55
62
|
const [, setTick] = useState(0);
|
|
@@ -58,7 +65,7 @@ export function unstable_useMessageStallDetection(
|
|
|
58
65
|
if (!running) return undefined;
|
|
59
66
|
lastActivityRef.current = Date.now();
|
|
60
67
|
return undefined;
|
|
61
|
-
}, [running,
|
|
68
|
+
}, [running, activity]);
|
|
62
69
|
|
|
63
70
|
useEffect(() => {
|
|
64
71
|
if (!running) {
|
|
@@ -75,7 +82,7 @@ export function unstable_useMessageStallDetection(
|
|
|
75
82
|
setStalled(false);
|
|
76
83
|
const id = setTimeout(() => setStalled(true), thresholdMs - sinceActivity);
|
|
77
84
|
return () => clearTimeout(id);
|
|
78
|
-
}, [running,
|
|
85
|
+
}, [running, activity, thresholdMs]);
|
|
79
86
|
|
|
80
87
|
useEffect(() => {
|
|
81
88
|
if (!stalled) return undefined;
|
|
@@ -267,43 +267,203 @@ describe("toWebMcpTool cancellation", () => {
|
|
|
267
267
|
});
|
|
268
268
|
});
|
|
269
269
|
|
|
270
|
-
it("
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
270
|
+
it.for(["caller", "lifecycle"] as const)(
|
|
271
|
+
"settles while async validation is pending when the %s signal aborts",
|
|
272
|
+
async (abortedSignal) => {
|
|
273
|
+
const lifecycle = new AbortController();
|
|
274
|
+
const caller = new AbortController();
|
|
275
|
+
let finishValidation!: (result: { issues?: readonly unknown[] }) => void;
|
|
276
|
+
const schema = z.object({ city: z.string() });
|
|
277
|
+
(schema as any)["~standard"] = {
|
|
278
|
+
...schema["~standard"],
|
|
279
|
+
validate: () =>
|
|
280
|
+
new Promise<{ issues?: readonly unknown[] }>((resolve) => {
|
|
281
|
+
finishValidation = resolve;
|
|
280
282
|
}),
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
+
};
|
|
284
|
+
const execute = vi.fn(async () => "never");
|
|
285
|
+
const pending = descriptorFor(
|
|
286
|
+
{ execute, parameters: schema },
|
|
287
|
+
lifecycle.signal,
|
|
288
|
+
).execute({ city: "Paris" }, { signal: caller.signal });
|
|
289
|
+
|
|
290
|
+
(abortedSignal === "caller" ? caller : lifecycle).abort();
|
|
291
|
+
|
|
292
|
+
await expect(pending).resolves.toEqual({
|
|
293
|
+
isError: true,
|
|
294
|
+
content: [text("Tool execution was cancelled.")],
|
|
295
|
+
});
|
|
296
|
+
expect(execute).not.toHaveBeenCalled();
|
|
297
|
+
finishValidation({});
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
it("consumes a validator rejection after cancellation", async () => {
|
|
302
|
+
const caller = new AbortController();
|
|
303
|
+
let failValidation!: (error: unknown) => void;
|
|
304
|
+
const schema = z.object({ city: z.string() });
|
|
305
|
+
(schema as any)["~standard"] = {
|
|
306
|
+
...schema["~standard"],
|
|
307
|
+
validate: () =>
|
|
308
|
+
new Promise((_resolve, reject) => {
|
|
309
|
+
failValidation = reject;
|
|
310
|
+
}),
|
|
311
|
+
};
|
|
312
|
+
const execute = vi.fn(async () => "never");
|
|
313
|
+
const pending = descriptorFor({ execute, parameters: schema }).execute(
|
|
314
|
+
{ city: "Paris" },
|
|
315
|
+
{ signal: caller.signal },
|
|
283
316
|
);
|
|
284
317
|
|
|
285
|
-
|
|
286
|
-
|
|
318
|
+
caller.abort();
|
|
319
|
+
|
|
287
320
|
await expect(pending).resolves.toEqual({
|
|
288
321
|
isError: true,
|
|
289
|
-
content: [text("
|
|
322
|
+
content: [text("Tool execution was cancelled.")],
|
|
290
323
|
});
|
|
324
|
+
failValidation(new Error("late validation failure"));
|
|
325
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
326
|
+
expect(execute).not.toHaveBeenCalled();
|
|
291
327
|
});
|
|
292
328
|
|
|
293
|
-
it("
|
|
329
|
+
it("prefers cancellation when validation aborts before rejecting", async () => {
|
|
330
|
+
const caller = new AbortController();
|
|
331
|
+
const schema = z.object({ city: z.string() });
|
|
332
|
+
(schema as any)["~standard"] = {
|
|
333
|
+
...schema["~standard"],
|
|
334
|
+
validate: () => {
|
|
335
|
+
caller.abort();
|
|
336
|
+
return Promise.reject(new Error("validation failed"));
|
|
337
|
+
},
|
|
338
|
+
};
|
|
294
339
|
const execute = vi.fn(async () => "never");
|
|
340
|
+
|
|
341
|
+
const result = await descriptorFor({ execute, parameters: schema }).execute(
|
|
342
|
+
{ city: "Paris" },
|
|
343
|
+
{ signal: caller.signal },
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
expect(result).toEqual({
|
|
347
|
+
isError: true,
|
|
348
|
+
content: [text("Tool execution was cancelled.")],
|
|
349
|
+
});
|
|
350
|
+
expect(execute).not.toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("does not execute when cancellation follows validation", async () => {
|
|
354
|
+
const caller = new AbortController();
|
|
355
|
+
const schema = z.object({ city: z.string() });
|
|
356
|
+
(schema as any)["~standard"] = {
|
|
357
|
+
...schema["~standard"],
|
|
358
|
+
validate: () => ({
|
|
359
|
+
then: (resolve: (value: { issues?: readonly unknown[] }) => void) => {
|
|
360
|
+
resolve({});
|
|
361
|
+
caller.abort();
|
|
362
|
+
},
|
|
363
|
+
}),
|
|
364
|
+
};
|
|
365
|
+
const execute = vi.fn(async () => "never");
|
|
366
|
+
|
|
367
|
+
const result = await descriptorFor({ execute, parameters: schema }).execute(
|
|
368
|
+
{ city: "Paris" },
|
|
369
|
+
{ signal: caller.signal },
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
expect(result).toEqual({
|
|
373
|
+
isError: true,
|
|
374
|
+
content: [text("Tool execution was cancelled.")],
|
|
375
|
+
});
|
|
376
|
+
expect(execute).not.toHaveBeenCalled();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it.for(["caller", "lifecycle"] as const)(
|
|
380
|
+
"merges signals without AbortSignal.any when the %s signal aborts",
|
|
381
|
+
async (abortedSignal) => {
|
|
382
|
+
const lifecycle = new AbortController();
|
|
383
|
+
const caller = new AbortController();
|
|
384
|
+
const abortSignalConstructor = AbortSignal as typeof AbortSignal & {
|
|
385
|
+
any?: (signals: Iterable<AbortSignal>) => AbortSignal;
|
|
386
|
+
};
|
|
387
|
+
const originalAbortSignalAny = abortSignalConstructor.any;
|
|
388
|
+
Object.defineProperty(abortSignalConstructor, "any", {
|
|
389
|
+
configurable: true,
|
|
390
|
+
value: () => {
|
|
391
|
+
throw new Error("AbortSignal.any is not available");
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
try {
|
|
395
|
+
const descriptor = descriptorFor(
|
|
396
|
+
{
|
|
397
|
+
execute: async (_args: unknown, context: any) =>
|
|
398
|
+
new Promise((_resolve, reject) => {
|
|
399
|
+
context.abortSignal.addEventListener("abort", () =>
|
|
400
|
+
reject(new Error("aborted")),
|
|
401
|
+
);
|
|
402
|
+
}),
|
|
403
|
+
},
|
|
404
|
+
lifecycle.signal,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const pending = descriptor.execute({}, { signal: caller.signal });
|
|
408
|
+
(abortedSignal === "caller" ? caller : lifecycle).abort();
|
|
409
|
+
await expect(pending).resolves.toEqual({
|
|
410
|
+
isError: true,
|
|
411
|
+
content: [text("aborted")],
|
|
412
|
+
});
|
|
413
|
+
} finally {
|
|
414
|
+
Object.defineProperty(abortSignalConstructor, "any", {
|
|
415
|
+
configurable: true,
|
|
416
|
+
value: originalAbortSignalAny,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
it("removes merged signal listeners after execution", async () => {
|
|
423
|
+
const lifecycle = new AbortController();
|
|
424
|
+
const caller = new AbortController();
|
|
425
|
+
const callerRemove = vi.spyOn(caller.signal, "removeEventListener");
|
|
426
|
+
const lifecycleRemove = vi.spyOn(lifecycle.signal, "removeEventListener");
|
|
427
|
+
|
|
428
|
+
const result = await descriptorFor(
|
|
429
|
+
{ execute: async () => "ok" },
|
|
430
|
+
lifecycle.signal,
|
|
431
|
+
).execute({}, { signal: caller.signal });
|
|
432
|
+
|
|
433
|
+
expect(result).toEqual({ content: [text("ok")] });
|
|
434
|
+
expect(callerRemove).toHaveBeenCalledTimes(1);
|
|
435
|
+
expect(lifecycleRemove).toHaveBeenCalledTimes(1);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("merges a caller signal that is not a native AbortSignal", async () => {
|
|
439
|
+
const listeners: (() => void)[] = [];
|
|
295
440
|
const foreignSignal = {
|
|
296
441
|
aborted: false,
|
|
297
|
-
|
|
442
|
+
reason: new Error("host cancelled"),
|
|
443
|
+
addEventListener: (_type: string, listener: () => void) => {
|
|
444
|
+
listeners.push(listener);
|
|
445
|
+
},
|
|
298
446
|
removeEventListener: () => {},
|
|
299
447
|
} as unknown as AbortSignal;
|
|
300
448
|
|
|
301
|
-
const
|
|
302
|
-
{
|
|
449
|
+
const pending = descriptorFor(
|
|
450
|
+
{
|
|
451
|
+
execute: async (_args: unknown, context: any) =>
|
|
452
|
+
new Promise((_resolve, reject) => {
|
|
453
|
+
context.abortSignal.addEventListener("abort", () =>
|
|
454
|
+
reject(new Error("aborted")),
|
|
455
|
+
);
|
|
456
|
+
}),
|
|
457
|
+
},
|
|
303
458
|
new AbortController().signal,
|
|
304
459
|
).execute({}, { signal: foreignSignal });
|
|
305
|
-
|
|
306
|
-
|
|
460
|
+
|
|
461
|
+
for (const listener of listeners) listener();
|
|
462
|
+
|
|
463
|
+
await expect(pending).resolves.toEqual({
|
|
464
|
+
isError: true,
|
|
465
|
+
content: [text("aborted")],
|
|
466
|
+
});
|
|
307
467
|
});
|
|
308
468
|
});
|
|
309
469
|
|