@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.
Files changed (100) hide show
  1. package/README.md +7 -1
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  8. package/dist/mcp-apps/app-frame.js +17 -12
  9. package/dist/mcp-apps/app-frame.js.map +1 -1
  10. package/dist/mcp-apps/bridge.js +7 -1
  11. package/dist/mcp-apps/bridge.js.map +1 -1
  12. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  13. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  14. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  15. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  16. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  17. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  18. package/dist/primitives/composer/ComposerInput.js +3 -8
  19. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  20. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  21. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  22. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  23. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  24. package/dist/primitives/composer/trigger/triggerNavigationResource.js +127 -137
  25. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  26. package/dist/primitives/message/MessageParts.js +2 -14
  27. package/dist/primitives/message/MessageParts.js.map +1 -1
  28. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  29. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  30. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  31. package/dist/primitives/messagePart/MessagePartText.d.ts +3 -1
  32. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  33. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  34. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  35. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  36. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  37. package/dist/sandbox-host/SandboxHost.js +22 -4
  38. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  39. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  40. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  41. package/dist/unstable/useMentionAdapter.js +2 -2
  42. package/dist/unstable/useMentionAdapter.js.map +1 -1
  43. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  44. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  45. package/dist/unstable/useMessageStallDetection.js +16 -15
  46. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  47. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -1
  48. package/dist/unstable/webmcp/convertTools.js +54 -3
  49. package/dist/unstable/webmcp/convertTools.js.map +1 -1
  50. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  51. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -1
  52. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  53. package/dist/utils/Primitive.d.ts +14 -6
  54. package/dist/utils/Primitive.d.ts.map +1 -1
  55. package/dist/utils/Primitive.js +25 -6
  56. package/dist/utils/Primitive.js.map +1 -1
  57. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  58. package/dist/utils/getSelectionMessageId.js +17 -3
  59. package/dist/utils/getSelectionMessageId.js.map +1 -1
  60. package/package.json +11 -11
  61. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  62. package/src/hooks/useToolCallElapsed.ts +3 -0
  63. package/src/index.ts +1 -1
  64. package/src/mcp-apps/app-frame.test.tsx +189 -0
  65. package/src/mcp-apps/app-frame.tsx +31 -22
  66. package/src/mcp-apps/bridge.test.ts +98 -0
  67. package/src/mcp-apps/bridge.ts +7 -1
  68. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  69. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  70. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  71. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  72. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  73. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  74. package/src/primitives/composer/ComposerInput.tsx +5 -9
  75. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  76. package/src/primitives/composer/trigger/triggerNavigationResource.ts +5 -3
  77. package/src/primitives/message/MessageParts.tsx +1 -21
  78. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  79. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  80. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  81. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  82. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  83. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  84. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  85. package/src/sandbox-host/SandboxHost.test.tsx +40 -0
  86. package/src/sandbox-host/SandboxHost.tsx +24 -4
  87. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  88. package/src/tests/toolCallTiming.test.tsx +55 -1
  89. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  90. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  91. package/src/unstable/useMentionAdapter.test.tsx +47 -1
  92. package/src/unstable/useMentionAdapter.ts +2 -2
  93. package/src/unstable/useMessageStallDetection.ts +26 -19
  94. package/src/unstable/webmcp/convertTools.test.ts +181 -21
  95. package/src/unstable/webmcp/convertTools.ts +87 -7
  96. package/src/unstable/webmcp/useWebMcpProvider.ts +2 -4
  97. package/src/utils/Primitive.test.tsx +46 -1
  98. package/src/utils/Primitive.tsx +37 -7
  99. package/src/utils/getSelectionMessageId.test.ts +176 -1
  100. 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
- useEffect(() => cancelTimer, [cancelTimer]);
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 if (
183
- pendingQueryRef.current !== null &&
184
- pendingQueryRef.current !== query
185
- ) {
186
- // the query returned to a cached value while a fetch for a different
187
- // query is in flight; drop it so its result cannot overwrite the cache
188
- queueMicrotask(invalidatePending);
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 { unstable_useMentionAdapter } from "./useMentionAdapter";
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: Record<string, string | undefined> = {};
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
- (categories !== undefined && categories.length > 0) ||
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 a fingerprint of its content (part count plus text,
27
- * argument, and result sizes) and reports a stall once the fingerprint stops
28
- * changing for `thresholdMs`. Useful for re-surfacing a "still working"
29
- * indicator during tool think-time or provider stalls, after the first
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 fingerprint = useAuiState((s) => {
40
- if (s.message.status?.type !== "running") return undefined;
41
- let size = 0;
42
- for (const part of s.message.content) {
43
- if (part.type === "text" || part.type === "reasoning") {
44
- size += part.text.length;
45
- } else if (part.type === "tool-call") {
46
- size += part.argsText.length + (part.result !== undefined ? 1 : 0);
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
- return `${s.message.content.length}:${size}`;
50
- });
55
+ return values;
56
+ }),
57
+ );
51
58
 
52
- const running = fingerprint !== undefined;
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, fingerprint]);
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, fingerprint, thresholdMs]);
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("merges the caller signal with the lifecycle signal", async () => {
271
- const lifecycle = new AbortController();
272
- const caller = new AbortController();
273
- const descriptor = descriptorFor(
274
- {
275
- execute: async (_args: unknown, context: any) =>
276
- new Promise((_resolve, reject) => {
277
- context.abortSignal.addEventListener("abort", () =>
278
- reject(new Error("aborted")),
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
- lifecycle.signal,
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
- const pending = descriptor.execute({}, { signal: caller.signal });
286
- lifecycle.abort();
318
+ caller.abort();
319
+
287
320
  await expect(pending).resolves.toEqual({
288
321
  isError: true,
289
- content: [text("aborted")],
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("returns an error result when the caller signal cannot be merged", async () => {
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
- addEventListener: () => {},
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 result = await descriptorFor(
302
- { execute },
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
- expect(result.isError).toBe(true);
306
- expect(execute).not.toHaveBeenCalled();
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