@assistant-ui/core 0.2.21 → 0.2.22

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 (209) hide show
  1. package/dist/adapters/attachment.d.ts.map +1 -1
  2. package/dist/adapters/speech.d.ts.map +1 -1
  3. package/dist/adapters/speech.js +9 -2
  4. package/dist/adapters/speech.js.map +1 -1
  5. package/dist/adapters/voice.d.ts.map +1 -1
  6. package/dist/adapters/voice.js +13 -5
  7. package/dist/adapters/voice.js.map +1 -1
  8. package/dist/internal.d.ts +3 -1
  9. package/dist/internal.js +3 -1
  10. package/dist/model-context/frame/host.d.ts +1 -0
  11. package/dist/model-context/frame/host.d.ts.map +1 -1
  12. package/dist/model-context/frame/host.js +5 -0
  13. package/dist/model-context/frame/host.js.map +1 -1
  14. package/dist/model-context/frame/provider.d.ts.map +1 -1
  15. package/dist/model-context/frame/provider.js +1 -0
  16. package/dist/model-context/frame/provider.js.map +1 -1
  17. package/dist/model-context/interactable-composer-metadata.js.map +1 -1
  18. package/dist/react/AssistantProvider.js.map +1 -1
  19. package/dist/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
  20. package/dist/react/adapters/LocalStorageThreadListAdapter.js +77 -50
  21. package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
  22. package/dist/react/client/Interactables.js.map +1 -1
  23. package/dist/react/client/Tools.d.ts.map +1 -1
  24. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  25. package/dist/react/model-context/useInteractable.js.map +1 -1
  26. package/dist/react/primitive-hooks/useActionBarCopy.js +13 -13
  27. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  28. package/dist/react/primitive-hooks/useComposerAddAttachment.d.ts.map +1 -1
  29. package/dist/react/primitive-hooks/useComposerSend.d.ts.map +1 -1
  30. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.d.ts.map +1 -1
  31. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js +1 -1
  32. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
  33. package/dist/react/primitives/composer/ComposerAttachments.js.map +1 -1
  34. package/dist/react/primitives/composer/ComposerQueue.d.ts.map +1 -1
  35. package/dist/react/primitives/composer/ComposerQueue.js.map +1 -1
  36. package/dist/react/primitives/generativeUI/GenerativeUI.d.ts.map +1 -1
  37. package/dist/react/primitives/message/MessageAttachments.js.map +1 -1
  38. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  39. package/dist/react/primitives/message/MessageParts.d.ts +2 -1
  40. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  41. package/dist/react/primitives/message/MessageParts.js +46 -36
  42. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  43. package/dist/react/primitives/part/PartMessages.js.map +1 -1
  44. package/dist/react/primitives/thread/ThreadMessages.js.map +1 -1
  45. package/dist/react/primitives/thread/ThreadSuggestions.js.map +1 -1
  46. package/dist/react/primitives/threadList/ThreadListItems.js.map +1 -1
  47. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.d.ts.map +1 -1
  48. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js +18 -30
  49. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js.map +1 -1
  50. package/dist/react/providers/PartByIndexProvider.d.ts.map +1 -1
  51. package/dist/react/providers/PartByIndexProvider.js +18 -30
  52. package/dist/react/providers/PartByIndexProvider.js.map +1 -1
  53. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  54. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +1 -0
  55. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  56. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +9 -2
  57. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  58. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +49 -8
  59. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  60. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +1 -1
  61. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  62. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
  63. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js +2 -1
  64. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js.map +1 -1
  65. package/dist/react/runtimes/createMessageConverter.d.ts.map +1 -1
  66. package/dist/react/runtimes/external-message-converter.js +1 -1
  67. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  68. package/dist/react/runtimes/useLocalRuntime.d.ts +1 -0
  69. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  70. package/dist/react/runtimes/useLocalRuntime.js +9 -1
  71. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  72. package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
  73. package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
  74. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  75. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  76. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  77. package/dist/runtime/api/message-runtime.js +4 -4
  78. package/dist/runtime/api/message-runtime.js.map +1 -1
  79. package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
  80. package/dist/runtime/api/thread-list-item-runtime.js +2 -1
  81. package/dist/runtime/api/thread-list-item-runtime.js.map +1 -1
  82. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  83. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  84. package/dist/runtime/base/base-composer-runtime-core.d.ts +4 -1
  85. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  86. package/dist/runtime/base/base-composer-runtime-core.js +41 -14
  87. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  88. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  89. package/dist/runtime/base/base-thread-runtime-core.js +2 -1
  90. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  91. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  92. package/dist/runtime/base/default-edit-composer-runtime-core.js +4 -2
  93. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  94. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  95. package/dist/runtime/base/default-thread-composer-runtime-core.js +2 -2
  96. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  97. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  98. package/dist/runtime/utils/thread-message-like.js +4 -1
  99. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  100. package/dist/runtimes/external-store/external-store-runtime-core.d.ts.map +1 -1
  101. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  102. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  103. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  104. package/dist/runtimes/local/local-runtime-core.d.ts.map +1 -1
  105. package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
  106. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  107. package/dist/runtimes/local/local-thread-runtime-core.js +5 -2
  108. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  109. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  110. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +4 -4
  111. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  112. package/dist/runtimes/remote-thread-list/optimistic-state.js +22 -9
  113. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  114. package/dist/runtimes/remote-thread-list/types.d.ts +1 -1
  115. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
  116. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +1 -2
  117. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
  118. package/dist/store/clients/chain-of-thought-client.d.ts.map +1 -1
  119. package/dist/store/clients/no-op-composer-client.d.ts.map +1 -1
  120. package/dist/store/runtime-clients/attachment-runtime-client.d.ts.map +1 -1
  121. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  122. package/dist/store/runtime-clients/message-part-runtime-client.d.ts.map +1 -1
  123. package/dist/store/runtime-clients/message-runtime-client.d.ts.map +1 -1
  124. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  125. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  126. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  127. package/dist/subscribable/subscribable.d.ts +1 -1
  128. package/dist/subscribable/subscribable.d.ts.map +1 -1
  129. package/dist/subscribable/subscribable.js +11 -3
  130. package/dist/subscribable/subscribable.js.map +1 -1
  131. package/dist/tests/remote-thread-list-test-helpers.d.ts +2 -1
  132. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  133. package/dist/tests/remote-thread-list-test-helpers.js +5 -2
  134. package/dist/tests/remote-thread-list-test-helpers.js.map +1 -1
  135. package/dist/types/attachment.d.ts +2 -1
  136. package/dist/types/attachment.d.ts.map +1 -1
  137. package/dist/types/attachment.js +6 -0
  138. package/dist/types/attachment.js.map +1 -0
  139. package/dist/types/message.d.ts +1 -0
  140. package/dist/types/message.d.ts.map +1 -1
  141. package/dist/types/message.js.map +1 -1
  142. package/dist/utils/data-url.d.ts +9 -0
  143. package/dist/utils/data-url.d.ts.map +1 -0
  144. package/dist/utils/data-url.js +14 -0
  145. package/dist/utils/data-url.js.map +1 -0
  146. package/dist/utils/notify-event-listeners.d.ts +6 -0
  147. package/dist/utils/notify-event-listeners.d.ts.map +1 -0
  148. package/dist/utils/notify-event-listeners.js +16 -0
  149. package/dist/utils/notify-event-listeners.js.map +1 -0
  150. package/package.json +10 -9
  151. package/src/adapters/speech.test.ts +88 -0
  152. package/src/adapters/speech.ts +17 -2
  153. package/src/adapters/voice.test.ts +130 -0
  154. package/src/adapters/voice.ts +26 -6
  155. package/src/internal.ts +5 -0
  156. package/src/model-context/frame/host.test.ts +100 -0
  157. package/src/model-context/frame/host.ts +10 -0
  158. package/src/model-context/frame/provider.test.ts +83 -0
  159. package/src/model-context/frame/provider.ts +1 -0
  160. package/src/react/adapters/LocalStorageThreadListAdapter.test.ts +82 -0
  161. package/src/react/adapters/LocalStorageThreadListAdapter.tsx +94 -54
  162. package/src/react/primitive-hooks/useActionBarCopy.test.ts +79 -0
  163. package/src/react/primitive-hooks/useActionBarCopy.ts +4 -3
  164. package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -3
  165. package/src/react/primitives/message/MessageParts.tsx +48 -35
  166. package/src/react/providers/ChainOfThoughtPartByIndexProvider.tsx +19 -2
  167. package/src/react/providers/PartByIndexProvider.tsx +16 -2
  168. package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +18 -0
  169. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +1 -0
  170. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +48 -7
  171. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +69 -0
  172. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +1 -1
  173. package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.test.ts +16 -0
  174. package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.ts +2 -1
  175. package/src/react/runtimes/external-message-converter.test.tsx +21 -0
  176. package/src/react/runtimes/external-message-converter.ts +1 -1
  177. package/src/react/runtimes/useLocalRuntime.test.tsx +56 -0
  178. package/src/react/runtimes/useLocalRuntime.ts +13 -1
  179. package/src/runtime/api/message-runtime.test.ts +81 -0
  180. package/src/runtime/api/message-runtime.ts +4 -6
  181. package/src/runtime/api/thread-list-item-runtime.ts +6 -1
  182. package/src/runtime/base/base-composer-runtime-core.ts +77 -31
  183. package/src/runtime/base/base-thread-runtime-core.ts +2 -1
  184. package/src/runtime/base/default-edit-composer-runtime-core.ts +4 -2
  185. package/src/runtime/base/default-thread-composer-runtime-core.ts +2 -2
  186. package/src/runtime/utils/thread-message-like.ts +1 -0
  187. package/src/runtimes/local/local-thread-runtime-core.test.ts +150 -1
  188. package/src/runtimes/local/local-thread-runtime-core.ts +4 -2
  189. package/src/runtimes/remote-thread-list/optimistic-state.test.ts +63 -0
  190. package/src/runtimes/remote-thread-list/optimistic-state.ts +49 -17
  191. package/src/runtimes/remote-thread-list/types.ts +1 -1
  192. package/src/runtimes/tool-invocations/EDGE_CASES.md +13 -19
  193. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +127 -6
  194. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +4 -2
  195. package/src/subscribable/subscribable.test.ts +58 -0
  196. package/src/subscribable/subscribable.ts +13 -3
  197. package/src/tests/OptimisticState-list-race.test.ts +33 -0
  198. package/src/tests/RemoteThreadListThreadListRuntimeCore-switchToThread-order.test.ts +223 -0
  199. package/src/tests/base-composer-runtime-core-addAttachment.test.ts +44 -0
  200. package/src/tests/base-composer-runtime-core-send.test.ts +433 -5
  201. package/src/tests/default-edit-composer-runtime-core.test.ts +43 -5
  202. package/src/tests/event-subscription-listener-errors.test.ts +205 -0
  203. package/src/tests/remote-thread-list-test-helpers.ts +14 -7
  204. package/src/tests/thread-message-like.test.ts +57 -0
  205. package/src/types/attachment.ts +5 -0
  206. package/src/types/message.ts +1 -0
  207. package/src/utils/data-url.test.ts +50 -0
  208. package/src/utils/data-url.ts +9 -0
  209. package/src/utils/notify-event-listeners.ts +30 -0
@@ -0,0 +1,69 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { renderHook } from "@testing-library/react";
4
+ import type { AssistantCloud } from "assistant-cloud";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { useAssistantCloudThreadHistoryAdapter } from "./AssistantCloudThreadHistoryAdapter";
7
+
8
+ const mocks = vi.hoisted(() => {
9
+ const threadListItem = {
10
+ getState: () => ({ remoteId: "thread-1" }),
11
+ };
12
+
13
+ return {
14
+ assistantClient: {
15
+ threadListItem: () => threadListItem,
16
+ },
17
+ };
18
+ });
19
+
20
+ vi.mock("@assistant-ui/store", async (importOriginal) => ({
21
+ ...(await importOriginal<typeof import("@assistant-ui/store")>()),
22
+ useAui: () => mocks.assistantClient,
23
+ }));
24
+
25
+ const makeCloud = () =>
26
+ ({
27
+ threads: {
28
+ messages: {
29
+ list: vi.fn().mockResolvedValue({ messages: [] }),
30
+ },
31
+ },
32
+ }) as unknown as AssistantCloud;
33
+
34
+ describe("useAssistantCloudThreadHistoryAdapter", () => {
35
+ it("refreshes formatted persistence when the Cloud client changes", async () => {
36
+ const firstCloud = makeCloud();
37
+ const secondCloud = makeCloud();
38
+ const cloudRef = { current: firstCloud };
39
+ const { result } = renderHook(() =>
40
+ useAssistantCloudThreadHistoryAdapter(cloudRef),
41
+ );
42
+ const formatted = result.current.withFormat<
43
+ { id: string },
44
+ Record<string, unknown>
45
+ >({
46
+ format: "test",
47
+ encode: ({ message }) => message,
48
+ decode: ({ parent_id, content }) => ({
49
+ parentId: parent_id,
50
+ message: content as { id: string },
51
+ }),
52
+ getId: (message) => message.id,
53
+ });
54
+
55
+ await formatted.load();
56
+ await formatted.load();
57
+
58
+ expect(firstCloud.threads.messages.list).toHaveBeenCalledTimes(2);
59
+
60
+ cloudRef.current = secondCloud;
61
+ await formatted.load();
62
+
63
+ expect(firstCloud.threads.messages.list).toHaveBeenCalledTimes(2);
64
+ expect(secondCloud.threads.messages.list).toHaveBeenCalledOnce();
65
+ expect(secondCloud.threads.messages.list).toHaveBeenCalledWith("thread-1", {
66
+ format: "test",
67
+ });
68
+ });
69
+ });
@@ -32,7 +32,7 @@ class AssistantCloudThreadHistoryAdapter implements ThreadHistoryAdapter {
32
32
  if (!globalPersistence.has(key)) {
33
33
  globalPersistence.set(
34
34
  key,
35
- new CloudMessagePersistence(this.cloudRef.current),
35
+ new CloudMessagePersistence(() => this.cloudRef.current),
36
36
  );
37
37
  }
38
38
  return globalPersistence.get(key)!;
@@ -57,6 +57,22 @@ describe("CloudFileAttachmentAdapter", () => {
57
57
  ]);
58
58
  });
59
59
 
60
+ it("uploads when Web Crypto is unavailable", async () => {
61
+ vi.stubGlobal("crypto", undefined);
62
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
63
+ const adapter = new CloudFileAttachmentAdapter(makeCloud());
64
+
65
+ const yields = await drain(adapter);
66
+
67
+ expect(yields).toHaveLength(2);
68
+ expect(yields[0]?.id).toBeTruthy();
69
+ expect(yields[1]?.id).toBe(yields[0]?.id);
70
+ expect(yields.at(-1)?.status).toEqual({
71
+ type: "requires-action",
72
+ reason: "composer-send",
73
+ });
74
+ });
75
+
60
76
  it("marks the attachment incomplete when the upload returns an HTTP error", async () => {
61
77
  const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
62
78
  vi.stubGlobal(
@@ -6,6 +6,7 @@ import type {
6
6
  } from "../../../types/attachment";
7
7
  import type { ThreadUserMessagePart } from "../../../types/message";
8
8
  import type { AttachmentAdapter } from "../../../adapters/attachment";
9
+ import { generateId } from "../../../utils/id";
9
10
 
10
11
  const guessAttachmentType = (
11
12
  contentType: string,
@@ -27,7 +28,7 @@ export class CloudFileAttachmentAdapter implements AttachmentAdapter {
27
28
  }: {
28
29
  file: File;
29
30
  }): AsyncGenerator<PendingAttachment, void> {
30
- const id = crypto.randomUUID();
31
+ const id = generateId();
31
32
  const type = guessAttachmentType(file.type);
32
33
  let attachment: PendingAttachment = {
33
34
  id,
@@ -99,4 +99,25 @@ describe("useExternalMessageConverter", () => {
99
99
  text: "HELLO",
100
100
  });
101
101
  });
102
+
103
+ it.each([
104
+ ["false", false],
105
+ ["zero", 0],
106
+ ["an empty string", ""],
107
+ ])("preserves %s assistant state", (_label, state) => {
108
+ const callback: useExternalMessageConverter.Callback<TestMessage> = (
109
+ message,
110
+ ) => ({
111
+ role: message.role,
112
+ id: message.id,
113
+ content: [{ type: "text", text: message.text }],
114
+ ...(message.role === "assistant"
115
+ ? { metadata: { unstable_state: state } }
116
+ : undefined),
117
+ });
118
+
119
+ const { result } = renderConverter({ callback });
120
+
121
+ expect(result.current[1]?.metadata.unstable_state).toBe(state);
122
+ });
102
123
  });
@@ -170,7 +170,7 @@ const joinExternalMessages = (
170
170
 
171
171
  if (output.metadata) {
172
172
  assistantMessage.metadata ??= {};
173
- if (output.metadata.unstable_state) {
173
+ if (output.metadata.unstable_state !== undefined) {
174
174
  assistantMessage.metadata.unstable_state =
175
175
  output.metadata.unstable_state;
176
176
  }
@@ -0,0 +1,56 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { render, waitFor } from "@testing-library/react";
4
+ import { StrictMode } from "react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { ChatModelAdapter } from "../../runtime/utils/chat-model-adapter";
7
+ import { AssistantRuntimeProvider } from "../AssistantRuntimeProvider";
8
+ import { useLocalRuntime } from "./useLocalRuntime";
9
+
10
+ const chatModel: ChatModelAdapter = {
11
+ run: async () => ({ content: [] }),
12
+ };
13
+
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ describe("useLocalRuntime", () => {
19
+ it("handles rejected history loads", async () => {
20
+ const error = new Error("history unavailable");
21
+ const consoleError = vi
22
+ .spyOn(console, "error")
23
+ .mockImplementation(() => {});
24
+ const history = {
25
+ load: vi.fn().mockRejectedValue(error),
26
+ append: vi.fn().mockResolvedValue(undefined),
27
+ };
28
+
29
+ const App = () => {
30
+ const runtime = useLocalRuntime(chatModel, { adapters: { history } });
31
+ return (
32
+ <AssistantRuntimeProvider runtime={runtime}>
33
+ <div />
34
+ </AssistantRuntimeProvider>
35
+ );
36
+ };
37
+
38
+ const renderApp = () => (
39
+ <StrictMode>
40
+ <App />
41
+ </StrictMode>
42
+ );
43
+ const { rerender } = render(renderApp());
44
+
45
+ await waitFor(() => {
46
+ expect(consoleError).toHaveBeenCalledWith(
47
+ "[assistant-ui] local thread history load failed:",
48
+ error,
49
+ );
50
+ });
51
+
52
+ rerender(renderApp());
53
+ expect(history.load).toHaveBeenCalledTimes(1);
54
+ expect(consoleError).toHaveBeenCalledTimes(1);
55
+ });
56
+ });
@@ -35,6 +35,7 @@ const useLocalThreadRuntime = (
35
35
  const [runtime] = useState(() => new LocalRuntimeCore(opt, initialMessages));
36
36
 
37
37
  const threadIdRef = useRef<string | undefined>(undefined);
38
+ const historyLoadPromiseRef = useRef<Promise<void> | undefined>(undefined);
38
39
  threadIdRef.current = useAuiState((s) => s.threadListItem.remoteId);
39
40
 
40
41
  useEffect(() => {
@@ -51,9 +52,20 @@ const useLocalThreadRuntime = (
51
52
 
52
53
  useEffect(() => {
53
54
  runtime.threads.getMainThreadRuntimeCore().__internal_setOptions(opt);
54
- runtime.threads.getMainThreadRuntimeCore().__internal_load();
55
55
  });
56
56
 
57
+ useEffect(() => {
58
+ const loadPromise = runtime.threads
59
+ .getMainThreadRuntimeCore()
60
+ .__internal_load();
61
+ if (historyLoadPromiseRef.current === loadPromise) return;
62
+
63
+ historyLoadPromiseRef.current = loadPromise;
64
+ void loadPromise.catch((error: unknown) => {
65
+ console.error("[assistant-ui] local thread history load failed:", error);
66
+ });
67
+ }, [runtime]);
68
+
57
69
  useEffect(() => {
58
70
  if (!modelContext) return undefined;
59
71
  return runtime.registerModelContextProvider(modelContext);
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { CompleteAttachment } from "../../types/attachment";
3
+ import type { ThreadRuntimeCoreBinding } from "./thread-runtime";
4
+ import {
5
+ MessageRuntimeImpl,
6
+ type MessageState,
7
+ type MessageStateBinding,
8
+ } from "./message-runtime";
9
+
10
+ const messagePath = {
11
+ ref: "threads.main.messages[0]",
12
+ threadSelector: { type: "main" as const },
13
+ messageSelector: { type: "index" as const, index: 0 },
14
+ };
15
+
16
+ const attachment: CompleteAttachment = {
17
+ id: "attachment-1",
18
+ type: "file",
19
+ name: "notes.txt",
20
+ contentType: "text/plain",
21
+ content: [{ type: "text", text: "notes" }],
22
+ status: { type: "complete" },
23
+ };
24
+
25
+ const message: MessageState = {
26
+ id: "message-1",
27
+ role: "assistant",
28
+ createdAt: new Date(0),
29
+ content: [
30
+ { type: "text", text: "Hello" },
31
+ {
32
+ type: "tool-call",
33
+ toolCallId: "call-1",
34
+ toolName: "weather",
35
+ args: {},
36
+ argsText: "{}",
37
+ },
38
+ ],
39
+ attachments: [attachment],
40
+ status: { type: "complete", reason: "stop" },
41
+ metadata: {
42
+ unstable_state: null,
43
+ unstable_annotations: [],
44
+ unstable_data: [],
45
+ steps: [],
46
+ custom: {},
47
+ },
48
+ parentId: null,
49
+ index: 0,
50
+ isLast: true,
51
+ branchNumber: 1,
52
+ branchCount: 1,
53
+ speech: undefined,
54
+ };
55
+
56
+ const messageBinding: MessageStateBinding = {
57
+ path: messagePath,
58
+ getState: () => message,
59
+ subscribe: () => () => {},
60
+ };
61
+
62
+ const threadBinding = {
63
+ subscribe: () => () => {},
64
+ } as unknown as ThreadRuntimeCoreBinding;
65
+
66
+ describe("MessageRuntimeImpl paths", () => {
67
+ it("appends nested selectors to the message path", () => {
68
+ const runtime = new MessageRuntimeImpl(messageBinding, threadBinding);
69
+
70
+ expect(runtime.composer.path.ref).toBe("threads.main.messages[0].composer");
71
+ expect(runtime.getMessagePartByIndex(0).path.ref).toBe(
72
+ "threads.main.messages[0].content[0]",
73
+ );
74
+ expect(runtime.getMessagePartByToolCallId("call-1").path.ref).toBe(
75
+ 'threads.main.messages[0].content[toolCallId="call-1"]',
76
+ );
77
+ expect(runtime.getAttachmentByIndex(0).path.ref).toBe(
78
+ "threads.main.messages[0].attachments[0]",
79
+ );
80
+ });
81
+ });
@@ -142,7 +142,7 @@ export class MessageRuntimeImpl implements MessageRuntime {
142
142
  new NestedSubscriptionSubject({
143
143
  path: {
144
144
  ...this.path,
145
- ref: `${this.path.ref}${this.path.ref}.composer`,
145
+ ref: `${this.path.ref}.composer`,
146
146
  composerSource: "edit",
147
147
  },
148
148
  getState: this._getEditComposerRuntimeCore,
@@ -269,7 +269,7 @@ export class MessageRuntimeImpl implements MessageRuntime {
269
269
  new ShallowMemoizeSubject({
270
270
  path: {
271
271
  ...this.path,
272
- ref: `${this.path.ref}${this.path.ref}.content[${idx}]`,
272
+ ref: `${this.path.ref}.content[${idx}]`,
273
273
  messagePartSelector: { type: "index", index: idx },
274
274
  },
275
275
  getState: () => {
@@ -287,9 +287,7 @@ export class MessageRuntimeImpl implements MessageRuntime {
287
287
  new ShallowMemoizeSubject({
288
288
  path: {
289
289
  ...this.path,
290
- ref:
291
- this.path.ref +
292
- `${this.path.ref}.content[toolCallId=${JSON.stringify(toolCallId)}]`,
290
+ ref: `${this.path.ref}.content[toolCallId=${JSON.stringify(toolCallId)}]`,
293
291
  messagePartSelector: { type: "toolCallId", toolCallId },
294
292
  },
295
293
  getState: () => {
@@ -313,7 +311,7 @@ export class MessageRuntimeImpl implements MessageRuntime {
313
311
  new ShallowMemoizeSubject({
314
312
  path: {
315
313
  ...this.path,
316
- ref: `${this.path.ref}${this.path.ref}.attachments[${idx}]`,
314
+ ref: `${this.path.ref}.attachments[${idx}]`,
317
315
  attachmentSource: "message",
318
316
  attachmentSelector: { type: "index", index: idx },
319
317
  },
@@ -2,6 +2,7 @@ import type { Unsubscribe } from "../../types/unsubscribe";
2
2
  import type { SubscribableWithState } from "../../subscribable/subscribable";
3
3
  import type { ThreadListItemRuntimePath } from "./paths";
4
4
  import type { ThreadListRuntimeCoreBinding } from "./thread-list-runtime";
5
+ import { notifyEventListeners } from "../../utils/notify-event-listeners";
5
6
 
6
7
  export type ThreadListItemEventPayload = {
7
8
  /**
@@ -162,7 +163,11 @@ export class ThreadListItemRuntimeImpl implements ThreadListItemRuntime {
162
163
 
163
164
  if (event === "switchedTo" && !newIsMain) return;
164
165
  if (event === "switchedAway" && newIsMain) return;
165
- (callback as (payload?: unknown) => void)({});
166
+ notifyEventListeners(
167
+ [callback as (payload?: unknown) => void],
168
+ {},
169
+ `Thread list item "${event}"`,
170
+ );
166
171
  });
167
172
  }
168
173
 
@@ -1,8 +1,9 @@
1
- import type {
2
- Attachment,
3
- CompleteAttachment,
4
- CreateAttachment,
5
- PendingAttachment,
1
+ import {
2
+ isCreateAttachment,
3
+ type Attachment,
4
+ type CompleteAttachment,
5
+ type CreateAttachment,
6
+ type PendingAttachment,
6
7
  } from "../../types/attachment";
7
8
  import type { MessageRole, AppendMessage } from "../../types/message";
8
9
  import type { QuoteInfo } from "../../types/quote";
@@ -28,6 +29,7 @@ import {
28
29
  type QueueItemState,
29
30
  } from "../../store/scopes/queue-item";
30
31
  import { generateId } from "../../utils/id";
32
+ import { notifyEventListeners } from "../../utils/notify-event-listeners";
31
33
 
32
34
  const isAttachmentComplete = (a: Attachment): a is CompleteAttachment =>
33
35
  a.status.type === "complete";
@@ -133,6 +135,10 @@ export abstract class BaseComposerRuntimeCore
133
135
  this._notifySubscribers();
134
136
  }
135
137
 
138
+ protected _isSending = false;
139
+ private _removedDuringSend = new Set<string>();
140
+ private _sendGeneration = 0;
141
+
136
142
  private _emptyTextAndAttachments() {
137
143
  this._attachments = [];
138
144
  this._text = "";
@@ -148,6 +154,14 @@ export abstract class BaseComposerRuntimeCore
148
154
  }
149
155
 
150
156
  public async reset() {
157
+ // A send whose adapter never settles must not brick the composer; reset is
158
+ // the escape hatch that releases the in-flight lock. Bumping the generation
159
+ // invalidates that send entirely so a late-settling upload can neither
160
+ // append the discarded draft nor touch a newer send's lock.
161
+ this._sendGeneration++;
162
+ this._isSending = false;
163
+ this._removedDuringSend.clear();
164
+
151
165
  if (
152
166
  this._attachments.length === 0 &&
153
167
  this._text === "" &&
@@ -175,7 +189,7 @@ export abstract class BaseComposerRuntimeCore
175
189
  }
176
190
 
177
191
  public async send(options?: SendOptions) {
178
- if (!this.canSend) return;
192
+ if (!this.canSend || this._isSending) return;
179
193
 
180
194
  if (this._dictationSession) {
181
195
  this._dictationSession.cancel();
@@ -183,47 +197,75 @@ export abstract class BaseComposerRuntimeCore
183
197
  }
184
198
 
185
199
  const adapter = this.getAttachmentAdapter();
186
- const attachments =
187
- this.attachments.length > 0
188
- ? Promise.all(
189
- this.attachments.map(async (a) => {
190
- if (isAttachmentComplete(a)) return a;
191
- if (!adapter) throw new Error("Attachments are not supported");
192
- const result = await adapter.send(a);
193
- return result as CompleteAttachment;
194
- }),
195
- )
196
- : [];
200
+ const attachmentTasks = this.attachments.map(async (a) => {
201
+ if (isAttachmentComplete(a)) return a;
202
+ if (!adapter) throw new Error("Attachments are not supported");
203
+ const result = await adapter.send(a);
204
+ return result as CompleteAttachment;
205
+ });
197
206
 
198
207
  const originalAttachments = this.attachments;
199
208
  const text = this.text;
200
209
  const quote = this._quote;
201
210
  this._quote = undefined;
202
- this._emptyTextAndAttachments();
211
+ this._text = "";
212
+ this._isSending = true;
213
+ const generation = ++this._sendGeneration;
214
+ this._notifySubscribers();
203
215
 
204
- let resolvedAttachments: Awaited<typeof attachments>;
216
+ let resolvedAttachments: CompleteAttachment[];
205
217
  try {
206
- resolvedAttachments = await attachments;
218
+ resolvedAttachments = await Promise.all(attachmentTasks);
207
219
  } catch (e) {
208
- if (this.isEmpty && this._quote === undefined) {
209
- this._attachments = originalAttachments;
210
- this._text = text;
211
- this._quote = quote;
212
- this._notifySubscribers();
220
+ if (generation === this._sendGeneration) {
221
+ if (!this.text.trim() && this._quote === undefined) {
222
+ this._text = text;
223
+ this._quote = quote;
224
+ this._notifySubscribers();
225
+ }
226
+ // Promise.all rejects on the first failure, but sibling uploads from
227
+ // this batch keep running; the send rejects immediately while the
228
+ // retry lock is held until they settle, or a retry could re-send
229
+ // attachments that are still in flight.
230
+ void Promise.allSettled(attachmentTasks).then(() => {
231
+ if (generation !== this._sendGeneration) return;
232
+ this._removedDuringSend.clear();
233
+ this._isSending = false;
234
+ this._notifySubscribers();
235
+ });
213
236
  }
214
237
  throw e;
215
238
  }
216
239
 
240
+ // A reset during the upload discarded this send's draft; the settled
241
+ // uploads must not append it or touch the lock a newer send may own.
242
+ if (generation !== this._sendGeneration) return;
243
+
244
+ // Drop by id, not wholesale: the user may have added or removed chips while
245
+ // the upload was in flight.
246
+ const sentIds = new Set(originalAttachments.map((a) => a.id));
247
+ this._attachments = this._attachments.filter((a) => !sentIds.has(a.id));
248
+ this._isSending = false;
249
+ this._notifySubscribers();
250
+
251
+ // An attachment removed mid-upload can't be cancelled, but it can still be
252
+ // dropped from the outgoing message instead of silently being sent anyway.
253
+ const finalAttachments = resolvedAttachments.filter(
254
+ (a) => !this._removedDuringSend.has(a.id),
255
+ );
256
+ this._removedDuringSend.clear();
257
+
217
258
  const message: Omit<AppendMessage, "parentId" | "sourceId"> = {
218
259
  createdAt: new Date(),
219
260
  role: this.role,
220
261
  content: text ? [{ type: "text", text }] : [],
221
- attachments: resolvedAttachments,
262
+ attachments: finalAttachments,
222
263
  runConfig: this.runConfig,
223
264
  metadata: { custom: { ...(quote ? { quote } : {}) } },
224
265
  };
225
266
 
226
- this.handleSend(message, options);
267
+ const sendTask = this.handleSend(message, options);
268
+ if (sendTask) void sendTask.catch(() => {});
227
269
  this._notifyEventSubscribers("send", {});
228
270
  }
229
271
 
@@ -241,11 +283,11 @@ export abstract class BaseComposerRuntimeCore
241
283
  protected abstract handleSend(
242
284
  message: Omit<AppendMessage, "parentId" | "sourceId">,
243
285
  options?: SendOptions,
244
- ): void;
286
+ ): void | Promise<void>;
245
287
  protected abstract handleCancel(): void;
246
288
 
247
289
  async addAttachment(fileOrAttachment: File | CreateAttachment) {
248
- if (!(fileOrAttachment instanceof File)) {
290
+ if (isCreateAttachment(fileOrAttachment)) {
249
291
  const adapter = this.getAttachmentAdapter();
250
292
  if (
251
293
  adapter &&
@@ -392,12 +434,16 @@ export abstract class BaseComposerRuntimeCore
392
434
  if (index === -1) throw new Error("Attachment not found");
393
435
  const attachment = this._attachments[index]!;
394
436
 
437
+ // A send in flight may already be uploading this attachment; the upload
438
+ // can't be cancelled, so mark it to be dropped from the outgoing message
439
+ // before any await gives the upload a chance to settle first.
440
+ if (this._isSending) this._removedDuringSend.add(attachmentId);
441
+
395
442
  if (!isAttachmentComplete(attachment)) {
396
443
  const adapter = this.getAttachmentAdapter();
397
444
  if (!adapter) throw new Error("Attachments are not supported");
398
445
  await adapter.remove(attachment);
399
446
  }
400
-
401
447
  this._attachments = this._attachments.filter((a) => a.id !== attachmentId);
402
448
  this._notifySubscribers();
403
449
  }
@@ -563,7 +609,7 @@ export abstract class BaseComposerRuntimeCore
563
609
  const subscribers = this._eventSubscribers.get(event);
564
610
  if (!subscribers) return;
565
611
 
566
- for (const callback of subscribers) callback(payload);
612
+ notifyEventListeners(subscribers, payload, `Composer runtime "${event}"`);
567
613
  }
568
614
 
569
615
  public unstable_on<E extends ComposerRuntimeEventType>(
@@ -34,6 +34,7 @@ import type { FeedbackAdapter } from "../../adapters/feedback";
34
34
  import type { AttachmentAdapter } from "../../adapters/attachment";
35
35
  import type { RealtimeVoiceAdapter } from "../../adapters/voice";
36
36
  import type { ThreadMessageLike } from "../utils/thread-message-like";
37
+ import { notifyEventListeners } from "../../utils/notify-event-listeners";
37
38
 
38
39
  type BaseThreadAdapters = {
39
40
  speech?: SpeechSynthesisAdapter | undefined;
@@ -182,7 +183,7 @@ export abstract class BaseThreadRuntimeCore implements ThreadRuntimeCore {
182
183
  const subscribers = this._eventSubscribers.get(event);
183
184
  if (!subscribers) return;
184
185
 
185
- for (const callback of subscribers) callback(payload);
186
+ notifyEventListeners(subscribers, payload, `Thread runtime "${event}"`);
186
187
  }
187
188
 
188
189
  public subscribe(callback: () => void): Unsubscribe {
@@ -15,7 +15,7 @@ export class DefaultEditComposerRuntimeCore extends BaseComposerRuntimeCore {
15
15
  }
16
16
 
17
17
  public get canSend() {
18
- return !this.isEmpty;
18
+ return !this.isEmpty && !this._isSending;
19
19
  }
20
20
 
21
21
  protected getAttachmentAdapter() {
@@ -80,6 +80,7 @@ export class DefaultEditComposerRuntimeCore extends BaseComposerRuntimeCore {
80
80
  message: Omit<AppendMessage, "parentId" | "sourceId">,
81
81
  options?: SendOptions,
82
82
  ) {
83
+ let appendTask: void | Promise<void> = undefined;
83
84
  const text = getThreadMessageText(message as AppendMessage);
84
85
  const attachmentsChanged = !attachmentsEqual(
85
86
  message.attachments ?? [],
@@ -114,7 +115,7 @@ export class DefaultEditComposerRuntimeCore extends BaseComposerRuntimeCore {
114
115
  message,
115
116
  composerMetadata,
116
117
  );
117
- this.runtime.append({
118
+ appendTask = this.runtime.append({
118
119
  ...enriched,
119
120
  content,
120
121
  parentId: this._parentId,
@@ -124,6 +125,7 @@ export class DefaultEditComposerRuntimeCore extends BaseComposerRuntimeCore {
124
125
  }
125
126
 
126
127
  this.handleCancel();
128
+ return appendTask;
127
129
  }
128
130
 
129
131
  public handleCancel() {
@@ -23,7 +23,7 @@ export class DefaultThreadComposerRuntimeCore
23
23
  }
24
24
 
25
25
  public get canSend() {
26
- return !this.isEmpty && !this.runtime.isSendDisabled;
26
+ return !this.isEmpty && !this.runtime.isSendDisabled && !this._isSending;
27
27
  }
28
28
 
29
29
  public override get queue(): readonly QueueItemState[] {
@@ -94,7 +94,7 @@ export class DefaultThreadComposerRuntimeCore
94
94
  );
95
95
  const enriched = this.enrichWithComposerMetadata(message, composerMetadata);
96
96
 
97
- this.runtime.append({
97
+ return this.runtime.append({
98
98
  ...(enriched as AppendMessage),
99
99
  parentId: this.runtime.messages.at(-1)?.id ?? null,
100
100
  sourceId: null,
@@ -266,6 +266,7 @@ export const fromThreadMessageLike = (
266
266
  })),
267
267
  metadata: {
268
268
  custom: metadata?.custom ?? {},
269
+ ...(metadata?.isOptimistic && { isOptimistic: true }),
269
270
  },
270
271
  } satisfies ThreadUserMessage;
271
272