@assistant-ui/core 0.3.1 → 0.3.2

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 (135) hide show
  1. package/dist/internal.d.ts +1 -2
  2. package/dist/internal.js +1 -2
  3. package/dist/react/AssistantProvider.d.ts +1 -1
  4. package/dist/react/AssistantProvider.d.ts.map +1 -1
  5. package/dist/react/AssistantProvider.js +35 -9
  6. package/dist/react/AssistantProvider.js.map +1 -1
  7. package/dist/react/AssistantRuntimeProvider.d.ts +1 -1
  8. package/dist/react/AssistantRuntimeProvider.js +1 -1
  9. package/dist/react/RuntimeAdapter.js +2 -2
  10. package/dist/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
  11. package/dist/react/adapters/LocalStorageThreadListAdapter.js +17 -33
  12. package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
  13. package/dist/react/client/DataRenderers.js +1 -1
  14. package/dist/react/client/Interactables.js +1 -1
  15. package/dist/react/client/Tools.js +2 -2
  16. package/dist/react/index.d.ts +2 -1
  17. package/dist/react/index.js +2 -1
  18. package/dist/react/interactables-legacy/Interactables.js +1 -1
  19. package/dist/react/interactables-legacy/useAssistantInteractable.js +1 -1
  20. package/dist/react/interactables-legacy/useInteractableState.js +2 -2
  21. package/dist/react/model-context/useAssistantContext.js +1 -1
  22. package/dist/react/model-context/useAssistantDataUI.js +2 -2
  23. package/dist/react/model-context/useAssistantInstructions.js +2 -2
  24. package/dist/react/model-context/useAssistantTool.js +2 -2
  25. package/dist/react/model-context/useAssistantToolUI.js +2 -2
  26. package/dist/react/model-context/useAuiToolOverrides.js +1 -1
  27. package/dist/react/model-context/useInlineRender.js +1 -1
  28. package/dist/react/model-context/useInteractable.js +1 -1
  29. package/dist/react/model-context/useInteractableState.js +2 -2
  30. package/dist/react/model-context/useInteractableVersions.js +2 -2
  31. package/dist/react/model-context/useToolArgsStatus.js +2 -2
  32. package/dist/react/primitive-hooks/useActionBarCopy.js +2 -2
  33. package/dist/react/primitive-hooks/useActionBarEdit.js +2 -2
  34. package/dist/react/primitive-hooks/useActionBarFeedback.js +2 -2
  35. package/dist/react/primitive-hooks/useActionBarReload.js +2 -2
  36. package/dist/react/primitive-hooks/useActionBarSpeak.js +2 -2
  37. package/dist/react/primitive-hooks/useActionBarStopSpeaking.js +2 -2
  38. package/dist/react/primitive-hooks/useBranchPickerNext.js +2 -2
  39. package/dist/react/primitive-hooks/useBranchPickerPrevious.js +2 -2
  40. package/dist/react/primitive-hooks/useComposerAddAttachment.js +2 -2
  41. package/dist/react/primitive-hooks/useComposerCancel.js +2 -2
  42. package/dist/react/primitive-hooks/useComposerDictate.js +2 -2
  43. package/dist/react/primitive-hooks/useComposerSend.js +2 -2
  44. package/dist/react/primitive-hooks/useEditComposerCancel.js +2 -2
  45. package/dist/react/primitive-hooks/useEditComposerSend.js +2 -2
  46. package/dist/react/primitive-hooks/useMessageBranching.js +2 -2
  47. package/dist/react/primitive-hooks/useMessageReload.js +2 -2
  48. package/dist/react/primitive-hooks/useSuggestionTrigger.js +2 -2
  49. package/dist/react/primitive-hooks/useThreadListItemArchive.js +2 -2
  50. package/dist/react/primitive-hooks/useThreadListItemDelete.js +2 -2
  51. package/dist/react/primitive-hooks/useThreadListItemTrigger.js +2 -2
  52. package/dist/react/primitive-hooks/useThreadListItemUnarchive.js +2 -2
  53. package/dist/react/primitive-hooks/useThreadListLoadMore.js +2 -2
  54. package/dist/react/primitive-hooks/useThreadListNew.js +2 -2
  55. package/dist/react/primitive-hooks/useThreadMessageIds.js +1 -1
  56. package/dist/react/primitive-hooks/useVoice.js +2 -2
  57. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js +2 -2
  58. package/dist/react/primitives/composer/ComposerAttachments.js +2 -2
  59. package/dist/react/primitives/composer/ComposerIf.js +1 -1
  60. package/dist/react/primitives/composer/ComposerQueue.js +1 -1
  61. package/dist/react/primitives/generativeUI/GenerativeUI.js +2 -2
  62. package/dist/react/primitives/message/MessageAttachments.js +2 -2
  63. package/dist/react/primitives/message/MessageGroupedParts.js +1 -1
  64. package/dist/react/primitives/message/MessageParts.js +2 -2
  65. package/dist/react/primitives/message/MessageQuote.js +2 -2
  66. package/dist/react/primitives/part/PartMessages.js +2 -2
  67. package/dist/react/primitives/thread/ThreadMessages.js +2 -2
  68. package/dist/react/primitives/thread/ThreadSuggestions.js +2 -2
  69. package/dist/react/primitives/threadList/ThreadListItems.js +2 -2
  70. package/dist/react/primitives/threadListItem/ThreadListItemTitle.js +1 -1
  71. package/dist/react/providers/AttachmentByIndexProvider.js +1 -1
  72. package/dist/react/providers/ChainOfThoughtByIndicesProvider.js +1 -1
  73. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js +1 -1
  74. package/dist/react/providers/MessageByIdProvider.js +1 -1
  75. package/dist/react/providers/MessageByIndexProvider.js +1 -1
  76. package/dist/react/providers/PartByIndexProvider.js +1 -1
  77. package/dist/react/providers/QueueItemByIndexProvider.js +1 -1
  78. package/dist/react/providers/ReadonlyThreadProvider.js +2 -2
  79. package/dist/react/providers/SuggestionByIndexProvider.js +1 -1
  80. package/dist/react/providers/TextMessagePartProvider.js +2 -2
  81. package/dist/react/providers/ThreadListItemByIndexProvider.js +1 -1
  82. package/dist/react/providers/ThreadListItemRuntimeProvider.js +1 -1
  83. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  84. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +14 -13
  85. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  86. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +3 -0
  87. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  88. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +22 -12
  89. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  90. package/dist/react/runtimes/RuntimeAdapterProvider.js +1 -1
  91. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +9 -23
  92. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  93. package/dist/react/runtimes/external-message-converter.js +1 -1
  94. package/dist/react/runtimes/useExternalStoreRuntime.js +1 -1
  95. package/dist/react/runtimes/useExternalStoreSharedOptions.js +1 -1
  96. package/dist/react/runtimes/useLocalRuntime.js +1 -1
  97. package/dist/react/runtimes/useRemoteThreadListRuntime.js +7 -13
  98. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  99. package/dist/react/utils/useJSONEqual.js +1 -1
  100. package/dist/runtime/utils/thread-message-like.js +2 -2
  101. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  102. package/dist/runtimes/remote-thread-list/types.d.ts +5 -4
  103. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  104. package/dist/store/clients/chain-of-thought-client.js +1 -1
  105. package/dist/store/clients/model-context-client.js +1 -1
  106. package/dist/store/clients/no-op-composer-client.js +1 -1
  107. package/dist/store/clients/suggestions.js +2 -2
  108. package/dist/store/clients/thread-message-client.js +2 -2
  109. package/dist/store/runtime-clients/composer-runtime-client.js +2 -2
  110. package/dist/store/runtime-clients/message-runtime-client.js +2 -2
  111. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +2 -2
  112. package/dist/store/runtime-clients/thread-list-runtime-client.js +2 -2
  113. package/dist/store/runtime-clients/thread-runtime-client.js +2 -2
  114. package/dist/store/scopes/thread.d.ts +1 -1
  115. package/dist/store/scopes/thread.d.ts.map +1 -1
  116. package/dist/utils/data-url.js +2 -2
  117. package/dist/utils/data-url.js.map +1 -1
  118. package/package.json +4 -4
  119. package/src/internal.ts +0 -7
  120. package/src/react/AssistantProvider.tsx +28 -15
  121. package/src/react/AssistantRuntimeProvider.test.tsx +101 -0
  122. package/src/react/adapters/LocalStorageThreadListAdapter.tsx +28 -7
  123. package/src/react/index.ts +4 -0
  124. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +16 -13
  125. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +53 -10
  126. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +7 -5
  127. package/src/react/runtimes/useRemoteThreadListRuntime.ts +9 -15
  128. package/src/runtime/utils/thread-message-like.ts +2 -2
  129. package/src/runtimes/remote-thread-list/types.ts +5 -4
  130. package/src/store/scopes/thread.ts +1 -1
  131. package/src/tests/remote-thread-list-reactive-threadId.test.ts +0 -92
  132. package/src/tests/thread-message-like.test.ts +15 -0
  133. package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +293 -0
  134. package/src/utils/data-url.test.ts +26 -2
  135. package/src/utils/data-url.ts +2 -2
@@ -2,98 +2,6 @@ import { describe, it, expect, vi } from "vitest";
2
2
  import type { RemoteThreadListAdapter } from "../runtimes/remote-thread-list/types";
3
3
  import { createCore, makeAdapter } from "./remote-thread-list-test-helpers";
4
4
 
5
- /**
6
- * Tests for the reactive threadId useEffect logic in useRemoteThreadListRuntime.
7
- *
8
- * The useEffect compares options.threadId against a prevThreadIdRef and calls
9
- * switchToThread or switchToNewThread when it changes. We test this logic
10
- * by simulating the ref + comparison pattern.
11
- */
12
-
13
- // Mirrors the useEffect in useRemoteThreadListRuntimeImpl.
14
- // Keep in sync if that implementation changes.
15
- function simulateThreadIdEffect(
16
- prevRef: { current: string | undefined },
17
- threadId: string | undefined,
18
- switchToThread: (id: string) => void,
19
- switchToNewThread: () => void,
20
- ) {
21
- if (threadId === prevRef.current) return;
22
- prevRef.current = threadId;
23
- if (threadId) {
24
- switchToThread(threadId);
25
- } else {
26
- switchToNewThread();
27
- }
28
- }
29
-
30
- describe("threadId reactive effect", () => {
31
- it("does nothing when threadId stays the same", () => {
32
- const ref = { current: "thread-1" };
33
- const switchToThread = vi.fn();
34
- const switchToNewThread = vi.fn();
35
-
36
- simulateThreadIdEffect(ref, "thread-1", switchToThread, switchToNewThread);
37
-
38
- expect(switchToThread).not.toHaveBeenCalled();
39
- expect(switchToNewThread).not.toHaveBeenCalled();
40
- });
41
-
42
- it("calls switchToThread when threadId changes", () => {
43
- const ref = { current: "thread-1" };
44
- const switchToThread = vi.fn();
45
- const switchToNewThread = vi.fn();
46
-
47
- simulateThreadIdEffect(ref, "thread-2", switchToThread, switchToNewThread);
48
-
49
- expect(switchToThread).toHaveBeenCalledWith("thread-2");
50
- expect(ref.current).toBe("thread-2");
51
- });
52
-
53
- it("calls switchToNewThread when threadId becomes undefined", () => {
54
- const ref = { current: "thread-1" };
55
- const switchToThread = vi.fn();
56
- const switchToNewThread = vi.fn();
57
-
58
- simulateThreadIdEffect(ref, undefined, switchToThread, switchToNewThread);
59
-
60
- expect(switchToNewThread).toHaveBeenCalledOnce();
61
- expect(ref.current).toBeUndefined();
62
- });
63
-
64
- it("skips switchToNewThread on first render when threadId is already undefined", () => {
65
- const ref = { current: undefined as string | undefined };
66
- const switchToThread = vi.fn();
67
- const switchToNewThread = vi.fn();
68
-
69
- // First render: undefined === undefined → skipped
70
- simulateThreadIdEffect(ref, undefined, switchToThread, switchToNewThread);
71
- expect(switchToNewThread).not.toHaveBeenCalled();
72
- });
73
-
74
- it("handles full navigation cycle", () => {
75
- const ref = { current: undefined as string | undefined };
76
- const switchToThread = vi.fn();
77
- const switchToNewThread = vi.fn();
78
-
79
- // Mount with thread-1
80
- simulateThreadIdEffect(ref, "thread-1", switchToThread, switchToNewThread);
81
- expect(switchToThread).toHaveBeenCalledWith("thread-1");
82
-
83
- // Navigate to thread-2
84
- simulateThreadIdEffect(ref, "thread-2", switchToThread, switchToNewThread);
85
- expect(switchToThread).toHaveBeenCalledWith("thread-2");
86
-
87
- // Navigate to new thread
88
- simulateThreadIdEffect(ref, undefined, switchToThread, switchToNewThread);
89
- expect(switchToNewThread).toHaveBeenCalledOnce();
90
-
91
- // Navigate back to thread-1
92
- simulateThreadIdEffect(ref, "thread-1", switchToThread, switchToNewThread);
93
- expect(switchToThread).toHaveBeenCalledTimes(3);
94
- });
95
- });
96
-
97
5
  /**
98
6
  * Tests for onThreadIdChange, driving the real
99
7
  * RemoteThreadListThreadListRuntimeCore via a mock adapter (no React). These
@@ -159,6 +159,21 @@ describe("fromThreadMessageLike", () => {
159
159
 
160
160
  expect(result.content).toEqual([]);
161
161
  });
162
+
163
+ it("keeps an image part with an uppercase-scheme data URL", () => {
164
+ const result = fromThreadMessageLike(
165
+ {
166
+ role: "assistant",
167
+ content: [{ type: "image", image: "DATA:IMAGE/PNG;base64,AAAA" }],
168
+ },
169
+ fallbackId,
170
+ fallbackStatus,
171
+ );
172
+
173
+ expect(result.content).toEqual([
174
+ { type: "image", image: "DATA:IMAGE/PNG;base64,AAAA" },
175
+ ]);
176
+ });
162
177
  });
163
178
 
164
179
  describe("providerMetadata passthrough", () => {
@@ -0,0 +1,293 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, waitFor } from "@testing-library/react";
4
+ import { useEffect } from "react";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { AssistantRuntimeProvider } from "../react/AssistantRuntimeProvider";
7
+ import { useExternalStoreRuntime } from "../react/runtimes/useExternalStoreRuntime";
8
+ import { useRemoteThreadListRuntime } from "../react/runtimes/useRemoteThreadListRuntime";
9
+ import type { AssistantRuntime } from "../runtime/api/assistant-runtime";
10
+ import type {
11
+ RemoteThreadListAdapter,
12
+ RemoteThreadMetadata,
13
+ } from "../runtimes/remote-thread-list/types";
14
+ import { deferred, makeAdapter } from "./remote-thread-list-test-helpers";
15
+
16
+ const EMPTY_MESSAGES: readonly never[] = [];
17
+
18
+ const makeThreadMetadata = (remoteId: string): RemoteThreadMetadata => ({
19
+ status: "regular",
20
+ remoteId,
21
+ externalId: remoteId,
22
+ title: "Test",
23
+ });
24
+
25
+ const useTestThreadRuntime = () =>
26
+ useExternalStoreRuntime({
27
+ messages: EMPTY_MESSAGES,
28
+ isRunning: false,
29
+ onNew: async () => {},
30
+ });
31
+
32
+ type RuntimeRef = {
33
+ current: AssistantRuntime | null;
34
+ };
35
+
36
+ const ControlledRuntime = ({
37
+ adapter,
38
+ threadId,
39
+ onThreadIdChange,
40
+ runtimeRef,
41
+ }: {
42
+ adapter: RemoteThreadListAdapter;
43
+ threadId: string | undefined;
44
+ onThreadIdChange: (threadId: string | undefined) => void;
45
+ runtimeRef: RuntimeRef;
46
+ }) => {
47
+ const runtime = useRemoteThreadListRuntime({
48
+ adapter,
49
+ threadId,
50
+ onThreadIdChange,
51
+ runtimeHook: useTestThreadRuntime,
52
+ });
53
+
54
+ useEffect(() => {
55
+ runtimeRef.current = runtime;
56
+ }, [runtime, runtimeRef]);
57
+
58
+ return (
59
+ <AssistantRuntimeProvider runtime={runtime}>
60
+ {null}
61
+ </AssistantRuntimeProvider>
62
+ );
63
+ };
64
+
65
+ const waitForRemoteThread = async (
66
+ runtimeRef: RuntimeRef,
67
+ remoteId: string,
68
+ ) => {
69
+ await waitFor(() => {
70
+ expect(runtimeRef.current).not.toBeNull();
71
+ expect(runtimeRef.current!.threads.mainItem.getState().remoteId).toBe(
72
+ remoteId,
73
+ );
74
+ });
75
+ };
76
+
77
+ describe("useRemoteThreadListRuntime controlled threadId", () => {
78
+ it("treats an initial empty string as a supplied thread ID", async () => {
79
+ const adapter = makeAdapter();
80
+ const onThreadIdChange = vi.fn();
81
+ const runtimeRef: RuntimeRef = { current: null };
82
+
83
+ render(
84
+ <ControlledRuntime
85
+ adapter={adapter}
86
+ threadId=""
87
+ onThreadIdChange={onThreadIdChange}
88
+ runtimeRef={runtimeRef}
89
+ />,
90
+ );
91
+
92
+ await waitForRemoteThread(runtimeRef, "");
93
+ expect(adapter.fetch).toHaveBeenCalledWith("");
94
+ expect(onThreadIdChange).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it("switches to an empty string supplied after mount", async () => {
98
+ const adapter = makeAdapter();
99
+ const onThreadIdChange = vi.fn();
100
+ const runtimeRef: RuntimeRef = { current: null };
101
+
102
+ const { rerender } = render(
103
+ <ControlledRuntime
104
+ adapter={adapter}
105
+ threadId="thread-a"
106
+ onThreadIdChange={onThreadIdChange}
107
+ runtimeRef={runtimeRef}
108
+ />,
109
+ );
110
+ await waitForRemoteThread(runtimeRef, "thread-a");
111
+
112
+ rerender(
113
+ <ControlledRuntime
114
+ adapter={adapter}
115
+ threadId=""
116
+ onThreadIdChange={onThreadIdChange}
117
+ runtimeRef={runtimeRef}
118
+ />,
119
+ );
120
+ await waitForRemoteThread(runtimeRef, "");
121
+ expect(adapter.fetch).toHaveBeenLastCalledWith("");
122
+ expect(onThreadIdChange).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it("does not echo prop-driven thread switches", async () => {
126
+ const adapter = makeAdapter();
127
+ const onThreadIdChange = vi.fn();
128
+ const runtimeRef: RuntimeRef = { current: null };
129
+
130
+ const { rerender } = render(
131
+ <ControlledRuntime
132
+ adapter={adapter}
133
+ threadId="thread-a"
134
+ onThreadIdChange={onThreadIdChange}
135
+ runtimeRef={runtimeRef}
136
+ />,
137
+ );
138
+
139
+ await waitForRemoteThread(runtimeRef, "thread-a");
140
+ expect(onThreadIdChange).not.toHaveBeenCalled();
141
+
142
+ rerender(
143
+ <ControlledRuntime
144
+ adapter={adapter}
145
+ threadId="thread-b"
146
+ onThreadIdChange={onThreadIdChange}
147
+ runtimeRef={runtimeRef}
148
+ />,
149
+ );
150
+
151
+ await waitForRemoteThread(runtimeRef, "thread-b");
152
+ expect(onThreadIdChange).not.toHaveBeenCalled();
153
+
154
+ const previousMainThreadId =
155
+ runtimeRef.current!.threads.getState().mainThreadId;
156
+ rerender(
157
+ <ControlledRuntime
158
+ adapter={adapter}
159
+ threadId={undefined}
160
+ onThreadIdChange={onThreadIdChange}
161
+ runtimeRef={runtimeRef}
162
+ />,
163
+ );
164
+
165
+ await waitFor(() => {
166
+ expect(runtimeRef.current!.threads.getState().mainThreadId).not.toBe(
167
+ previousMainThreadId,
168
+ );
169
+ expect(
170
+ runtimeRef.current!.threads.mainItem.getState().remoteId,
171
+ ).toBeUndefined();
172
+ });
173
+ expect(onThreadIdChange).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it("still emits runtime-initiated thread switches", async () => {
177
+ const adapter = makeAdapter();
178
+ const onThreadIdChange = vi.fn();
179
+ const runtimeRef: RuntimeRef = { current: null };
180
+
181
+ render(
182
+ <ControlledRuntime
183
+ adapter={adapter}
184
+ threadId="thread-a"
185
+ onThreadIdChange={onThreadIdChange}
186
+ runtimeRef={runtimeRef}
187
+ />,
188
+ );
189
+
190
+ await waitForRemoteThread(runtimeRef, "thread-a");
191
+ onThreadIdChange.mockClear();
192
+
193
+ await act(async () => {
194
+ await runtimeRef.current!.threads.switchToThread("thread-b");
195
+ });
196
+ expect(onThreadIdChange).toHaveBeenLastCalledWith("thread-b");
197
+
198
+ onThreadIdChange.mockClear();
199
+ await act(async () => {
200
+ await runtimeRef.current!.threads.switchToNewThread();
201
+ });
202
+ expect(onThreadIdChange).toHaveBeenLastCalledWith(undefined);
203
+ });
204
+
205
+ it("does not retain suppression after an initial switch fails", async () => {
206
+ let threadAFetchCount = 0;
207
+ const adapter = makeAdapter({
208
+ fetch: vi.fn(async (threadId) => {
209
+ if (threadId === "thread-a" && threadAFetchCount++ === 0) {
210
+ throw new Error("initial fetch failed");
211
+ }
212
+ return makeThreadMetadata(threadId);
213
+ }),
214
+ });
215
+ const onThreadIdChange = vi.fn();
216
+ const runtimeRef: RuntimeRef = { current: null };
217
+
218
+ render(
219
+ <ControlledRuntime
220
+ adapter={adapter}
221
+ threadId="thread-a"
222
+ onThreadIdChange={onThreadIdChange}
223
+ runtimeRef={runtimeRef}
224
+ />,
225
+ );
226
+
227
+ await waitFor(() => {
228
+ expect(adapter.fetch).toHaveBeenCalledWith("thread-a");
229
+ expect(runtimeRef.current).not.toBeNull();
230
+ });
231
+
232
+ await act(async () => {
233
+ await runtimeRef.current!.threads.switchToThread("thread-b");
234
+ });
235
+ expect(onThreadIdChange).toHaveBeenLastCalledWith("thread-b");
236
+
237
+ onThreadIdChange.mockClear();
238
+ await act(async () => {
239
+ await runtimeRef.current!.threads.switchToThread("thread-a");
240
+ });
241
+ expect(onThreadIdChange).toHaveBeenLastCalledWith("thread-a");
242
+ });
243
+
244
+ it("emits a concurrent runtime switch to the controlled target", async () => {
245
+ const firstThreadBFetch = deferred<RemoteThreadMetadata>();
246
+ let threadBFetchCount = 0;
247
+ const adapter = makeAdapter({
248
+ fetch: vi.fn(async (threadId) => {
249
+ if (threadId === "thread-b" && threadBFetchCount++ === 0) {
250
+ return firstThreadBFetch.promise;
251
+ }
252
+ return makeThreadMetadata(threadId);
253
+ }),
254
+ });
255
+ const onThreadIdChange = vi.fn();
256
+ const runtimeRef: RuntimeRef = { current: null };
257
+
258
+ const { rerender } = render(
259
+ <ControlledRuntime
260
+ adapter={adapter}
261
+ threadId="thread-a"
262
+ onThreadIdChange={onThreadIdChange}
263
+ runtimeRef={runtimeRef}
264
+ />,
265
+ );
266
+
267
+ await waitForRemoteThread(runtimeRef, "thread-a");
268
+ onThreadIdChange.mockClear();
269
+
270
+ rerender(
271
+ <ControlledRuntime
272
+ adapter={adapter}
273
+ threadId="thread-b"
274
+ onThreadIdChange={onThreadIdChange}
275
+ runtimeRef={runtimeRef}
276
+ />,
277
+ );
278
+ await waitFor(() => {
279
+ expect(adapter.fetch).toHaveBeenCalledWith("thread-b");
280
+ });
281
+
282
+ await act(async () => {
283
+ await runtimeRef.current!.threads.switchToThread("thread-b");
284
+ });
285
+ expect(onThreadIdChange).toHaveBeenCalledExactlyOnceWith("thread-b");
286
+
287
+ await act(async () => {
288
+ firstThreadBFetch.resolve(makeThreadMetadata("thread-b"));
289
+ await firstThreadBFetch.promise;
290
+ });
291
+ expect(onThreadIdChange).toHaveBeenCalledExactlyOnceWith("thread-b");
292
+ });
293
+ });
@@ -20,8 +20,32 @@ describe("parseDataUrl", () => {
20
20
  expect(parseDataUrl("data:text/plain,hello")).toBeNull();
21
21
  });
22
22
 
23
- it("returns null for data URLs with an empty payload", () => {
24
- expect(parseDataUrl("data:image/png;base64,")).toBeNull();
23
+ it("parses a data URL with an empty payload as zero-byte data", () => {
24
+ expect(parseDataUrl("data:image/png;base64,")).toEqual({
25
+ mimeType: "image/png",
26
+ data: "",
27
+ });
28
+ });
29
+
30
+ it("parses an uppercase scheme", () => {
31
+ expect(parseDataUrl("DATA:image/png;base64,aGVsbG8=")).toEqual({
32
+ mimeType: "image/png",
33
+ data: "aGVsbG8=",
34
+ });
35
+ });
36
+
37
+ it("parses a mixed-case scheme and base64 token", () => {
38
+ expect(parseDataUrl("Data:image/png;Base64,aGVsbG8=")).toEqual({
39
+ mimeType: "image/png",
40
+ data: "aGVsbG8=",
41
+ });
42
+ });
43
+
44
+ it("lowercases the captured mime type", () => {
45
+ expect(parseDataUrl("data:IMAGE/PNG;base64,aGVsbG8=")).toEqual({
46
+ mimeType: "image/png",
47
+ data: "aGVsbG8=",
48
+ });
25
49
  });
26
50
 
27
51
  it("returns null for plain strings", () => {
@@ -3,7 +3,7 @@ export const httpUrlPattern = /^https?:\/\//i;
3
3
  export function parseDataUrl(
4
4
  value: string,
5
5
  ): { mimeType: string; data: string } | null {
6
- const match = value.match(/^data:([^;,]+)(?:;[^;,]+)*;base64,(.+)$/);
6
+ const match = value.match(/^data:([^;,]+)(?:;[^;,]+)*;base64,(.*)$/i);
7
7
  if (!match) return null;
8
- return { mimeType: match[1]!, data: match[2]! };
8
+ return { mimeType: match[1]!.toLowerCase(), data: match[2]! };
9
9
  }