@assistant-ui/core 0.3.14 → 0.3.15

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 (137) hide show
  1. package/dist/adapters/voice.js +11 -0
  2. package/dist/adapters/voice.js.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/internal.d.ts +2 -1
  5. package/dist/internal.js +2 -1
  6. package/dist/model-context/frame/host.d.ts +1 -1
  7. package/dist/model-context/frame/host.d.ts.map +1 -1
  8. package/dist/model-context/frame/host.js +17 -3
  9. package/dist/model-context/frame/host.js.map +1 -1
  10. package/dist/model-context/frame/provider.d.ts +4 -0
  11. package/dist/model-context/frame/provider.d.ts.map +1 -1
  12. package/dist/model-context/frame/provider.js +50 -3
  13. package/dist/model-context/frame/provider.js.map +1 -1
  14. package/dist/model-context/frame/types.d.ts +4 -1
  15. package/dist/model-context/frame/types.d.ts.map +1 -1
  16. package/dist/model-context/frame/types.js.map +1 -1
  17. package/dist/react/client/RemoteThreadList.d.ts +1 -1
  18. package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
  19. package/dist/react/client/RemoteThreadList.js +95 -23
  20. package/dist/react/client/RemoteThreadList.js.map +1 -1
  21. package/dist/react/primitive-hooks/useActionBarCopy.js +7 -1
  22. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  23. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts +3 -0
  24. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts.map +1 -1
  25. package/dist/react/primitive-hooks/useThreadIsEmpty.js +3 -0
  26. package/dist/react/primitive-hooks/useThreadIsEmpty.js.map +1 -1
  27. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts +3 -0
  28. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts.map +1 -1
  29. package/dist/react/primitive-hooks/useThreadIsRunning.js +3 -0
  30. package/dist/react/primitive-hooks/useThreadIsRunning.js.map +1 -1
  31. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -0
  32. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  33. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +6 -0
  34. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  35. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -0
  36. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  37. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +192 -50
  38. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  39. package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
  40. package/dist/react/runtimes/RemoteThreadResource.js +2 -0
  41. package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
  42. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  43. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js +1 -1
  44. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  45. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  46. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +38 -5
  47. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  48. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  49. package/dist/runtime/utils/message-repository.js +27 -18
  50. package/dist/runtime/utils/message-repository.js.map +1 -1
  51. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  52. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -0
  53. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  54. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts +102 -0
  55. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts.map +1 -0
  56. package/dist/runtimes/inert/InertThreadRuntimeCore.js +156 -0
  57. package/dist/runtimes/inert/InertThreadRuntimeCore.js.map +1 -0
  58. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  59. package/dist/runtimes/local/local-thread-runtime-core.js +25 -6
  60. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  61. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +12 -60
  62. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  63. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +10 -145
  64. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  65. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts +9 -0
  66. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts.map +1 -0
  67. package/dist/runtimes/remote-thread-list/adapter-changed.js +13 -0
  68. package/dist/runtimes/remote-thread-list/adapter-changed.js.map +1 -0
  69. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  70. package/dist/runtimes/remote-thread-list/empty-thread-core.js +16 -155
  71. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  72. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +2 -0
  73. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  74. package/dist/runtimes/remote-thread-list/optimistic-state.js +11 -0
  75. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  76. package/dist/runtimes/remote-thread-list/types.d.ts +3 -0
  77. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  78. package/dist/store/clients/external-thread.js +1 -1
  79. package/dist/store/clients/external-thread.js.map +1 -1
  80. package/dist/store/runtime-clients/handle-runtime-action.d.ts.map +1 -1
  81. package/dist/store/runtime-clients/handle-runtime-action.js +2 -0
  82. package/dist/store/runtime-clients/handle-runtime-action.js.map +1 -1
  83. package/dist/types/message.d.ts +1 -1
  84. package/dist/types/message.js.map +1 -1
  85. package/dist/utils/silent-runtime-action.d.ts +6 -0
  86. package/dist/utils/silent-runtime-action.d.ts.map +1 -0
  87. package/dist/utils/silent-runtime-action.js +7 -0
  88. package/dist/utils/silent-runtime-action.js.map +1 -0
  89. package/package.json +4 -4
  90. package/src/adapters/voice.test.ts +66 -1
  91. package/src/adapters/voice.ts +8 -0
  92. package/src/internal.ts +1 -0
  93. package/src/model-context/frame/host.test.ts +64 -5
  94. package/src/model-context/frame/host.ts +16 -3
  95. package/src/model-context/frame/provider.test.ts +347 -20
  96. package/src/model-context/frame/provider.ts +58 -2
  97. package/src/model-context/frame/types.ts +5 -0
  98. package/src/react/client/RemoteThreadList.test.ts +196 -0
  99. package/src/react/client/RemoteThreadList.ts +93 -24
  100. package/src/react/primitive-hooks/useActionBarCopy.test.ts +15 -0
  101. package/src/react/primitive-hooks/useActionBarCopy.ts +13 -3
  102. package/src/react/primitive-hooks/useThreadIsEmpty.ts +3 -0
  103. package/src/react/primitive-hooks/useThreadIsRunning.ts +3 -0
  104. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +12 -0
  105. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.republish.test.ts +87 -0
  106. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +268 -63
  107. package/src/react/runtimes/RemoteThreadResource.ts +2 -0
  108. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.test.tsx +58 -0
  109. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.ts +3 -1
  110. package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +98 -3
  111. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +68 -6
  112. package/src/react/runtimes/useLocalRuntime.test.tsx +5 -5
  113. package/src/runtime/utils/message-repository.ts +26 -18
  114. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +4 -0
  115. package/src/runtimes/inert/InertThreadRuntimeCore.test.ts +253 -0
  116. package/src/runtimes/inert/InertThreadRuntimeCore.ts +232 -0
  117. package/src/runtimes/local/local-thread-runtime-core.test.ts +319 -0
  118. package/src/runtimes/local/local-thread-runtime-core.ts +38 -8
  119. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +17 -209
  120. package/src/runtimes/remote-thread-list/adapter-changed.ts +10 -0
  121. package/src/runtimes/remote-thread-list/empty-thread-core.ts +28 -221
  122. package/src/runtimes/remote-thread-list/optimistic-state.test.ts +18 -0
  123. package/src/runtimes/remote-thread-list/optimistic-state.ts +13 -0
  124. package/src/runtimes/remote-thread-list/types.ts +4 -0
  125. package/src/store/clients/external-thread.ts +1 -1
  126. package/src/store/runtime-clients/handle-runtime-action.test.ts +40 -0
  127. package/src/store/runtime-clients/handle-runtime-action.ts +3 -0
  128. package/src/tests/MessageRepository.test.ts +48 -0
  129. package/src/tests/RemoteThreadListThreadListRuntimeCore-errors.test.ts +2 -2
  130. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +8 -6
  131. package/src/tests/empty-thread-core.test.ts +6 -0
  132. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +20 -0
  133. package/src/tests/external-thread-isEmpty.test.tsx +100 -0
  134. package/src/tests/remote-thread-list-adapter-switch.test.ts +383 -0
  135. package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +67 -0
  136. package/src/types/message.ts +1 -1
  137. package/src/utils/silent-runtime-action.ts +6 -0
@@ -4,6 +4,7 @@ import { act, render, waitFor } from "@testing-library/react";
4
4
  import type { FC } from "react";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { useAui } from "@assistant-ui/store";
7
+ import { ToolResponse } from "assistant-stream";
7
8
  import { AssistantRuntimeProvider } from "../../AssistantRuntimeProvider";
8
9
  import {
9
10
  useAssistantTransportRuntime,
@@ -145,6 +146,63 @@ afterEach(() => {
145
146
  });
146
147
 
147
148
  describe("useAssistantTransportRuntime", () => {
149
+ it.each([false, 0, "", null])(
150
+ "preserves the falsy tool artifact %j",
151
+ async (artifact) => {
152
+ const fetchMock = installFetch();
153
+ const { aui } = mountRuntime({
154
+ converter: (_state, meta) => ({
155
+ messages: [
156
+ {
157
+ id: "a1",
158
+ role: "assistant",
159
+ content: [
160
+ {
161
+ type: "tool-call",
162
+ toolCallId: "tc1",
163
+ toolName: "probe",
164
+ args: {},
165
+ argsText: "{}",
166
+ },
167
+ ],
168
+ createdAt: new Date(0),
169
+ status: { type: "requires-action", reason: "tool-calls" },
170
+ metadata: { custom: {} },
171
+ },
172
+ ],
173
+ isRunning: meta.isSending,
174
+ }),
175
+ });
176
+
177
+ await waitFor(() =>
178
+ expect(
179
+ aui()
180
+ .thread.message({ id: "a1" })
181
+ .part({ toolCallId: "tc1" })
182
+ .getState().type,
183
+ ).toBe("tool-call"),
184
+ );
185
+
186
+ act(() => {
187
+ aui()
188
+ .thread.message({ id: "a1" })
189
+ .part({ toolCallId: "tc1" })
190
+ .addToolResult(new ToolResponse({ result: "ok", artifact }));
191
+ });
192
+
193
+ await waitFor(() => expect(fetchMock.requests).toHaveLength(1));
194
+ expect(fetchMock.requests[0]!.body["commands"][0]).toHaveProperty(
195
+ "artifact",
196
+ artifact,
197
+ );
198
+
199
+ act(() => fetchMock.servers[0]!.close());
200
+ await waitFor(() =>
201
+ expect(aui().thread.getState().isRunning).toBe(false),
202
+ );
203
+ },
204
+ );
205
+
148
206
  it.each(["throws", "rejects"] as const)(
149
207
  "cancels the response body when onResponse %s",
150
208
  async (failureMode) => {
@@ -447,7 +447,9 @@ const useAssistantTransportThreadRuntime = <T>(
447
447
  result: toolOptions.result as ReadonlyJSONObject,
448
448
  toolName: toolOptions.toolName,
449
449
  isError: toolOptions.isError,
450
- ...(toolOptions.artifact && { artifact: toolOptions.artifact }),
450
+ ...(toolOptions.artifact !== undefined && {
451
+ artifact: toolOptions.artifact,
452
+ }),
451
453
  ...(toolOptions.modelContent !== undefined && {
452
454
  modelContent: toolOptions.modelContent,
453
455
  }),
@@ -19,7 +19,38 @@ const makeThread = (id: string) => ({
19
19
  });
20
20
 
21
21
  describe("useCloudThreadListAdapter", () => {
22
- it("exposes full Cloud pages through the remote thread cursor", async () => {
22
+ it("loads archived Cloud threads alongside regular threads", async () => {
23
+ const activeThreads = [makeThread("active-1")];
24
+ const archivedThreads = [
25
+ { ...makeThread("archived-1"), is_archived: true },
26
+ ];
27
+ const list = vi
28
+ .fn()
29
+ .mockResolvedValueOnce({ threads: activeThreads })
30
+ .mockResolvedValueOnce({ threads: archivedThreads });
31
+ const cloud = { threads: { list } } as unknown as AssistantCloud;
32
+ const { result } = renderHook(() => useCloudThreadListAdapter({ cloud }));
33
+
34
+ const page = await result.current.list();
35
+
36
+ expect(list).toHaveBeenNthCalledWith(1, { limit: 20 });
37
+ expect(list).toHaveBeenNthCalledWith(2, {
38
+ is_archived: true,
39
+ limit: 20,
40
+ });
41
+ expect(page.threads.map((thread) => thread.remoteId)).toEqual([
42
+ "active-1",
43
+ "archived-1",
44
+ ]);
45
+ expect(
46
+ page.threads.find((thread) => thread.remoteId === "active-1")?.status,
47
+ ).toBe("regular");
48
+ expect(
49
+ page.threads.find((thread) => thread.remoteId === "archived-1")?.status,
50
+ ).toBe("archived");
51
+ });
52
+
53
+ it("continues the active list once the archived list is exhausted", async () => {
23
54
  const firstPage = Array.from({ length: 20 }, (_, index) =>
24
55
  makeThread(`thread-${index + 1}`),
25
56
  );
@@ -27,17 +58,20 @@ describe("useCloudThreadListAdapter", () => {
27
58
  const list = vi
28
59
  .fn()
29
60
  .mockResolvedValueOnce({ threads: firstPage })
61
+ .mockResolvedValueOnce({ threads: [] })
30
62
  .mockResolvedValueOnce({ threads: secondPage });
31
63
  const cloud = { threads: { list } } as unknown as AssistantCloud;
32
64
  const { result } = renderHook(() => useCloudThreadListAdapter({ cloud }));
33
65
 
34
66
  const first = await result.current.list();
35
67
  expect(list).toHaveBeenNthCalledWith(1, { limit: 20 });
68
+ expect(list).toHaveBeenNthCalledWith(2, { is_archived: true, limit: 20 });
36
69
  expect(first.threads).toHaveLength(20);
37
- expect(first.nextCursor).toBe("thread-20");
70
+ expect(JSON.parse(first.nextCursor!)).toEqual({ a: "thread-20", re: true });
38
71
 
39
72
  const second = await result.current.list({ after: first.nextCursor });
40
- expect(list).toHaveBeenNthCalledWith(2, {
73
+ expect(list).toHaveBeenCalledTimes(3);
74
+ expect(list).toHaveBeenNthCalledWith(3, {
41
75
  limit: 20,
42
76
  after: "thread-20",
43
77
  });
@@ -46,4 +80,65 @@ describe("useCloudThreadListAdapter", () => {
46
80
  ]);
47
81
  expect(second.nextCursor).toBeUndefined();
48
82
  });
83
+
84
+ it("paginates both legs without dropping or duplicating items", async () => {
85
+ const activePage1 = Array.from({ length: 20 }, (_, index) =>
86
+ makeThread(`a-${index + 1}`),
87
+ );
88
+ const archivedPage1 = Array.from({ length: 20 }, (_, index) => ({
89
+ ...makeThread(`r-${index + 1}`),
90
+ is_archived: true,
91
+ }));
92
+ const activePage2 = [makeThread("a-21")];
93
+ const archivedPage2 = [{ ...makeThread("r-21"), is_archived: true }];
94
+ const list = vi
95
+ .fn()
96
+ .mockResolvedValueOnce({ threads: activePage1 })
97
+ .mockResolvedValueOnce({ threads: archivedPage1 })
98
+ .mockResolvedValueOnce({ threads: activePage2 })
99
+ .mockResolvedValueOnce({ threads: archivedPage2 });
100
+ const cloud = { threads: { list } } as unknown as AssistantCloud;
101
+ const { result } = renderHook(() => useCloudThreadListAdapter({ cloud }));
102
+
103
+ const page1 = await result.current.list();
104
+ expect(page1.threads).toHaveLength(40);
105
+ expect(JSON.parse(page1.nextCursor!)).toEqual({ a: "a-20", r: "r-20" });
106
+
107
+ const page2 = await result.current.list({ after: page1.nextCursor });
108
+ expect(list).toHaveBeenNthCalledWith(3, { limit: 20, after: "a-20" });
109
+ expect(list).toHaveBeenNthCalledWith(4, {
110
+ is_archived: true,
111
+ limit: 20,
112
+ after: "r-20",
113
+ });
114
+ expect(page2.threads.map((thread) => thread.remoteId)).toEqual([
115
+ "a-21",
116
+ "r-21",
117
+ ]);
118
+ expect(page2.nextCursor).toBeUndefined();
119
+ });
120
+
121
+ it("accepts a legacy bare-id cursor for the active list", async () => {
122
+ const activeThreads = [makeThread("thread-21")];
123
+ const list = vi
124
+ .fn()
125
+ .mockResolvedValueOnce({ threads: activeThreads })
126
+ .mockResolvedValueOnce({ threads: [] });
127
+ const cloud = { threads: { list } } as unknown as AssistantCloud;
128
+ const { result } = renderHook(() => useCloudThreadListAdapter({ cloud }));
129
+
130
+ const page = await result.current.list({ after: "thread-20" });
131
+
132
+ expect(list).toHaveBeenNthCalledWith(1, {
133
+ limit: 20,
134
+ after: "thread-20",
135
+ });
136
+ expect(list).toHaveBeenNthCalledWith(2, {
137
+ is_archived: true,
138
+ limit: 20,
139
+ });
140
+ expect(page.threads.map((thread) => thread.remoteId)).toEqual([
141
+ "thread-21",
142
+ ]);
143
+ });
49
144
  });
@@ -44,6 +44,35 @@ const autoCloud = baseUrl
44
44
 
45
45
  const CLOUD_THREAD_PAGE_SIZE = 20;
46
46
 
47
+ type CloudListCursor = {
48
+ activeCursor: string | undefined;
49
+ archivedCursor: string | undefined;
50
+ activeExhausted: boolean;
51
+ archivedExhausted: boolean;
52
+ };
53
+
54
+ const parseListCursor = (after: string | undefined): CloudListCursor => {
55
+ const fallback: CloudListCursor = {
56
+ activeCursor: after,
57
+ archivedCursor: undefined,
58
+ activeExhausted: false,
59
+ archivedExhausted: false,
60
+ };
61
+ if (!after || !after.startsWith("{")) return fallback;
62
+ try {
63
+ const parsed = JSON.parse(after);
64
+ if (!isRecord(parsed)) return fallback;
65
+ return {
66
+ activeCursor: typeof parsed.a === "string" ? parsed.a : undefined,
67
+ archivedCursor: typeof parsed.r === "string" ? parsed.r : undefined,
68
+ activeExhausted: parsed.ae === true,
69
+ archivedExhausted: parsed.re === true,
70
+ };
71
+ } catch {
72
+ return fallback;
73
+ }
74
+ };
75
+
47
76
  const useCloudThreadListAdapters = (
48
77
  adapterRef: RefObject<CloudThreadListAdapterOptions>,
49
78
  ): RuntimeAdapters => {
@@ -105,10 +134,38 @@ export const useCloudThreadListAdapter = (
105
134
 
106
135
  return {
107
136
  list: async ({ after } = {}) => {
108
- const { threads } = await cloud.threads.list({
109
- limit: CLOUD_THREAD_PAGE_SIZE,
110
- ...(after ? { after } : {}),
111
- });
137
+ const {
138
+ activeCursor,
139
+ archivedCursor,
140
+ activeExhausted,
141
+ archivedExhausted,
142
+ } = parseListCursor(after);
143
+ const [{ threads: activeThreads }, { threads: archivedThreads }] =
144
+ await Promise.all([
145
+ activeExhausted
146
+ ? Promise.resolve({ threads: [] })
147
+ : cloud.threads.list({
148
+ limit: CLOUD_THREAD_PAGE_SIZE,
149
+ ...(activeCursor ? { after: activeCursor } : {}),
150
+ }),
151
+ archivedExhausted
152
+ ? Promise.resolve({ threads: [] })
153
+ : cloud.threads.list({
154
+ is_archived: true,
155
+ limit: CLOUD_THREAD_PAGE_SIZE,
156
+ ...(archivedCursor ? { after: archivedCursor } : {}),
157
+ }),
158
+ ]);
159
+ const activeNext =
160
+ !activeExhausted && activeThreads.length === CLOUD_THREAD_PAGE_SIZE
161
+ ? activeThreads.at(-1)?.id
162
+ : undefined;
163
+ const archivedNext =
164
+ !archivedExhausted &&
165
+ archivedThreads.length === CLOUD_THREAD_PAGE_SIZE
166
+ ? archivedThreads.at(-1)?.id
167
+ : undefined;
168
+ const threads = [...activeThreads, ...archivedThreads];
112
169
  return {
113
170
  threads: threads.map((t) => ({
114
171
  status: t.is_archived ? "archived" : "regular",
@@ -121,8 +178,13 @@ export const useCloudThreadListAdapter = (
121
178
  custom: toCustom(t.metadata),
122
179
  })),
123
180
  nextCursor:
124
- threads.length === CLOUD_THREAD_PAGE_SIZE
125
- ? threads.at(-1)?.id
181
+ activeNext || archivedNext
182
+ ? JSON.stringify({
183
+ a: activeNext,
184
+ r: archivedNext,
185
+ ...(activeNext === undefined ? { ae: true } : {}),
186
+ ...(archivedNext === undefined ? { re: true } : {}),
187
+ })
126
188
  : undefined,
127
189
  };
128
190
  },
@@ -83,17 +83,17 @@ describe("useLocalRuntime", () => {
83
83
  const { rerender } = render(<App cloud={firstCloud} label="first" />);
84
84
 
85
85
  await waitFor(() => {
86
- expect(firstCloud.threads.list).toHaveBeenCalledOnce();
86
+ expect(firstCloud.threads.list).toHaveBeenCalledTimes(2);
87
87
  });
88
88
 
89
89
  rerender(<App cloud={firstCloud} label="second" />);
90
- expect(firstCloud.threads.list).toHaveBeenCalledOnce();
90
+ expect(firstCloud.threads.list).toHaveBeenCalledTimes(2);
91
91
 
92
92
  rerender(<App cloud={secondCloud} label="third" />);
93
93
  await waitFor(() => {
94
- expect(secondCloud.threads.list).toHaveBeenCalledOnce();
94
+ expect(secondCloud.threads.list).toHaveBeenCalledTimes(2);
95
95
  });
96
- expect(firstCloud.threads.list).toHaveBeenCalledOnce();
96
+ expect(firstCloud.threads.list).toHaveBeenCalledTimes(2);
97
97
  });
98
98
 
99
99
  it("uses the current Cloud client for attachment uploads", async () => {
@@ -118,7 +118,7 @@ describe("useLocalRuntime", () => {
118
118
  const { rerender } = render(<App cloud={firstCloud} />);
119
119
 
120
120
  await waitFor(() => {
121
- expect(firstCloud.threads.list).toHaveBeenCalledOnce();
121
+ expect(firstCloud.threads.list).toHaveBeenCalledTimes(2);
122
122
  });
123
123
 
124
124
  rerender(<App cloud={secondCloud} />);
@@ -81,9 +81,9 @@ type RepositoryMessage = RepositoryParent & {
81
81
  const findHead = (
82
82
  message: RepositoryMessage | RepositoryParent,
83
83
  ): RepositoryMessage | null => {
84
- if (message.next) return findHead(message.next);
85
- if ("current" in message) return message;
86
- return null;
84
+ let current = message;
85
+ while (current.next) current = current.next;
86
+ return "current" in current ? current : null;
87
87
  };
88
88
 
89
89
  class CachedValue<T> {
@@ -116,11 +116,17 @@ export class MessageRepository {
116
116
  };
117
117
 
118
118
  private updateLevels(message: RepositoryMessage, newLevel: number) {
119
- message.level = newLevel;
120
- for (const childId of message.children) {
121
- const childMessage = this.messages.get(childId);
122
- if (childMessage) {
123
- this.updateLevels(childMessage, newLevel + 1);
119
+ const pending = [{ message, level: newLevel }];
120
+
121
+ while (pending.length > 0) {
122
+ const current = pending.pop()!;
123
+ current.message.level = current.level;
124
+
125
+ for (const childId of current.message.children) {
126
+ const childMessage = this.messages.get(childId);
127
+ if (childMessage) {
128
+ pending.push({ message: childMessage, level: current.level + 1 });
129
+ }
124
130
  }
125
131
  }
126
132
  }
@@ -135,7 +141,8 @@ export class MessageRepository {
135
141
 
136
142
  if (operation === "relink" && parentOrRoot === newParentOrRoot) return;
137
143
 
138
- if (operation !== "cut") {
144
+ // `link` receives a fresh ID from `addOrUpdateMessage`; only `relink` can introduce a cycle.
145
+ if (operation === "relink") {
139
146
  for (
140
147
  let current: RepositoryMessage | null = newParent;
141
148
  current;
@@ -143,7 +150,7 @@ export class MessageRepository {
143
150
  ) {
144
151
  if (current.current.id === child.current.id) {
145
152
  throw new Error(
146
- "MessageRepository(performOp/link): A message with the same id already exists in the parent tree. This error occurs if the same message id is found multiple times. This is likely an internal bug in assistant-ui.",
153
+ "MessageRepository(performOp/relink): A message with the same id already exists in the parent tree. This error occurs if the same message id is found multiple times. This is likely an internal bug in assistant-ui.",
147
154
  );
148
155
  }
149
156
  }
@@ -396,16 +403,17 @@ export class MessageRepository {
396
403
  const previousHead = this.head;
397
404
 
398
405
  if (message.children.length > 0) {
399
- const deleteDescendants = (msg: RepositoryMessage) => {
400
- for (const childId of msg.children) {
401
- const childMessage = this.messages.get(childId);
402
- if (childMessage) {
403
- deleteDescendants(childMessage);
404
- this.messages.delete(childId);
406
+ const pending = [...message.children];
407
+ while (pending.length > 0) {
408
+ const childId = pending.pop()!;
409
+ const childMessage = this.messages.get(childId);
410
+ if (childMessage) {
411
+ for (const descendantId of childMessage.children) {
412
+ pending.push(descendantId);
405
413
  }
414
+ this.messages.delete(childId);
406
415
  }
407
- };
408
- deleteDescendants(message);
416
+ }
409
417
 
410
418
  message.children = [];
411
419
  message.next = null;
@@ -674,6 +674,8 @@ export class ExternalStoreThreadRuntimeCore
674
674
  if (!this._store.onCancel)
675
675
  throw new Error("Runtime does not support cancelling runs.");
676
676
 
677
+ const generation = captureThreadRuntimeGeneration(this);
678
+
677
679
  // Abort any in-flight client-side tool executions. Fire-and-forget —
678
680
  // the abort resolves once executions settle, but we don't gate the
679
681
  // cancel on it.
@@ -731,6 +733,8 @@ export class ExternalStoreThreadRuntimeCore
731
733
  // tick. Read the repository at flush time and re-apply the rollbacks to
732
734
  // it, instead of stamping a snapshot captured above over the newer state.
733
735
  setTimeout(() => {
736
+ if (!isThreadRuntimeGenerationCurrent(this, generation)) return;
737
+
734
738
  this.dropEmptyOptimisticHead();
735
739
  if (movedLeaf) {
736
740
  const current = this.repository.getMessages();
@@ -0,0 +1,253 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { BaseSubscribable } from "../../subscribable/subscribable";
3
+ import { ReadonlyThreadRuntimeCore } from "../readonly/ReadonlyThreadRuntimeCore";
4
+ import { EMPTY_THREAD_CORE } from "../remote-thread-list/empty-thread-core";
5
+
6
+ const READONLY_ERROR = /readonly thread/;
7
+ const EMPTY_ERROR = /empty thread/;
8
+
9
+ const SHARED_THROWING_METHODS = [
10
+ "switchToBranch",
11
+ "append",
12
+ "deleteMessage",
13
+ "startRun",
14
+ "resumeRun",
15
+ "unstable_notifySessionReset",
16
+ "addToolResult",
17
+ "resumeToolCall",
18
+ "respondToToolApproval",
19
+ "speak",
20
+ "connectVoice",
21
+ "muteVoice",
22
+ "unmuteVoice",
23
+ "submitFeedback",
24
+ "exportExternalState",
25
+ "importExternalState",
26
+ "beginEdit",
27
+ "import",
28
+ "reset",
29
+ ] as const;
30
+
31
+ const SHARED_THROWING_COMPOSER_METHODS = [
32
+ "setText",
33
+ "setRole",
34
+ "setRunConfig",
35
+ "send",
36
+ "startDictation",
37
+ "setQuote",
38
+ ] as const;
39
+
40
+ const SHARED_NOOP_COMPOSER_METHODS = [
41
+ "cancel",
42
+ "stopDictation",
43
+ "moveQueueItem",
44
+ "removeQueueItem",
45
+ ] as const;
46
+
47
+ type AnyCore = Record<string, any>;
48
+
49
+ const cores = [
50
+ [
51
+ "ReadonlyThreadRuntimeCore",
52
+ () => new ReadonlyThreadRuntimeCore() as unknown as AnyCore,
53
+ READONLY_ERROR,
54
+ ],
55
+ [
56
+ "EMPTY_THREAD_CORE",
57
+ () => EMPTY_THREAD_CORE as unknown as AnyCore,
58
+ EMPTY_ERROR,
59
+ ],
60
+ ] as const;
61
+
62
+ describe.each(cores)("%s shared inert surface", (_name, makeCore, error) => {
63
+ it.each(SHARED_THROWING_METHODS)("%s throws the core's error", (method) => {
64
+ expect(() => makeCore()[method]!()).toThrow(error);
65
+ });
66
+
67
+ it.each(SHARED_THROWING_COMPOSER_METHODS)(
68
+ "composer.%s throws the core's error",
69
+ (method) => {
70
+ expect(() => makeCore().composer[method]!()).toThrow(error);
71
+ },
72
+ );
73
+
74
+ it.each(["addAttachment", "removeAttachment"] as const)(
75
+ "composer.%s rejects with the core's error",
76
+ async (method) => {
77
+ await expect(makeCore().composer[method]!()).rejects.toThrow(error);
78
+ },
79
+ );
80
+
81
+ it.each(SHARED_NOOP_COMPOSER_METHODS)("composer.%s is a no-op", (method) => {
82
+ expect(() => makeCore().composer[method]!()).not.toThrow();
83
+ });
84
+
85
+ it("composer.reset and composer.clearAttachments resolve without throwing", async () => {
86
+ const core = makeCore();
87
+ await expect(core.composer.reset()).resolves.toBeUndefined();
88
+ await expect(core.composer.clearAttachments()).resolves.toBeUndefined();
89
+ });
90
+
91
+ it("reports every capability as disabled", () => {
92
+ expect(makeCore().capabilities).toEqual({
93
+ switchToBranch: false,
94
+ switchBranchDuringRun: false,
95
+ edit: false,
96
+ delete: false,
97
+ reload: false,
98
+ refetchThread: false,
99
+ cancel: false,
100
+ unstable_copy: false,
101
+ speech: false,
102
+ dictation: false,
103
+ voice: false,
104
+ attachments: false,
105
+ feedback: false,
106
+ queue: false,
107
+ });
108
+ });
109
+
110
+ it("exposes inert read-only state", () => {
111
+ const core = makeCore();
112
+ expect(core.getModelContext()).toEqual({});
113
+ expect(core.getEditComposer()).toBeUndefined();
114
+ expect(core.getVoiceVolume()).toBe(0);
115
+ expect(core.speech).toBeUndefined();
116
+ expect(core.voice).toBeUndefined();
117
+ expect(core.state).toBeNull();
118
+ expect(core.suggestions).toEqual([]);
119
+ expect(core.extras).toBeUndefined();
120
+ expect(core.isDisabled).toBe(false);
121
+ expect(core.isSendDisabled).toBe(false);
122
+ });
123
+
124
+ it("exposes an empty composer that accepts everything and holds nothing", () => {
125
+ const composer = makeCore().composer;
126
+ expect(composer.attachmentAccept).toBe("*");
127
+ expect(composer.attachments).toEqual([]);
128
+ expect(composer.text).toBe("");
129
+ expect(composer.role).toBe("user");
130
+ expect(composer.runConfig).toEqual({});
131
+ expect(composer.queue).toEqual([]);
132
+ expect(composer.dictation).toBeUndefined();
133
+ expect(composer.quote).toBeUndefined();
134
+ expect(composer.isEmpty).toBe(true);
135
+ expect(composer.canSend).toBe(false);
136
+ expect(composer.canCancel).toBe(false);
137
+ });
138
+
139
+ it("throws one stable error object from every mutator", () => {
140
+ const core = makeCore();
141
+ const thrown = SHARED_THROWING_METHODS.map((method) => {
142
+ try {
143
+ core[method]!();
144
+ return undefined;
145
+ } catch (e) {
146
+ return e;
147
+ }
148
+ });
149
+
150
+ expect(thrown[0]).toBeInstanceOf(Error);
151
+ expect(thrown.every((e) => e === thrown[0])).toBe(true);
152
+ });
153
+
154
+ it("throws a receiver TypeError when a mutator is detached", () => {
155
+ const detached = makeCore().append!;
156
+ expect(() => detached()).toThrow(TypeError);
157
+ });
158
+
159
+ it("subscribeVoiceVolume and unstable_on hand back no-op unsubscribers", () => {
160
+ const core = makeCore();
161
+ expect(() => core.subscribeVoiceVolume(() => {})()).not.toThrow();
162
+ expect(() => core.unstable_on("runStart", () => {})()).not.toThrow();
163
+ });
164
+ });
165
+
166
+ describe("no-op versus throw divergences", () => {
167
+ it.each(["cancelRun", "stopSpeaking", "disconnectVoice"] as const)(
168
+ "%s is a no-op on the readonly core but throws on the empty core",
169
+ (method) => {
170
+ const readonly = new ReadonlyThreadRuntimeCore() as unknown as AnyCore;
171
+ expect(() => readonly[method]!()).not.toThrow();
172
+ expect(() =>
173
+ (EMPTY_THREAD_CORE as unknown as AnyCore)[method]!(),
174
+ ).toThrow(EMPTY_ERROR);
175
+ },
176
+ );
177
+
178
+ it("readonly is not loading; empty is loading so it is not read as an empty conversation", () => {
179
+ expect(new ReadonlyThreadRuntimeCore().isLoading).toBe(false);
180
+ expect(EMPTY_THREAD_CORE.isLoading).toBe(true);
181
+ });
182
+
183
+ it("readonly composer is not editing; empty composer is", () => {
184
+ expect(new ReadonlyThreadRuntimeCore().composer.isEditing).toBe(false);
185
+ expect(EMPTY_THREAD_CORE.composer.isEditing).toBe(true);
186
+ });
187
+
188
+ it("the two cores carry distinct error objects and messages", () => {
189
+ const grab = (fn: () => void) => {
190
+ try {
191
+ fn();
192
+ return undefined;
193
+ } catch (e) {
194
+ return e as Error;
195
+ }
196
+ };
197
+
198
+ const readonlyError = grab(() => new ReadonlyThreadRuntimeCore().append());
199
+ const emptyError = grab(() =>
200
+ (EMPTY_THREAD_CORE as unknown as AnyCore).append!(),
201
+ );
202
+
203
+ expect(readonlyError).not.toBe(emptyError);
204
+ expect(readonlyError?.message).toBe(
205
+ "This is a readonly thread. You cannot perform mutations on readonly threads.",
206
+ );
207
+ expect(emptyError?.message).toMatch(/placeholder for the main thread/);
208
+ });
209
+ });
210
+
211
+ describe("readonly notifications", () => {
212
+ it("notifies subscribers and settles waitForUpdate when messages change", async () => {
213
+ const core = new ReadonlyThreadRuntimeCore();
214
+ const seen: number[] = [];
215
+ const unsubscribe = core.subscribe(() => {
216
+ seen.push(core.messages.length);
217
+ });
218
+ const update = core.waitForUpdate();
219
+
220
+ core.setMessages([
221
+ {
222
+ id: "m1",
223
+ role: "user",
224
+ content: [{ type: "text", text: "hi" }],
225
+ createdAt: new Date(0),
226
+ status: { type: "complete", reason: "stop" },
227
+ metadata: { custom: {} },
228
+ } as any,
229
+ ]);
230
+
231
+ await expect(update).resolves.toBeUndefined();
232
+ expect(seen).toEqual([1]);
233
+ unsubscribe();
234
+ });
235
+ });
236
+
237
+ describe("empty singleton", () => {
238
+ it("does not retain subscribe callbacks", () => {
239
+ const subscribers = (
240
+ EMPTY_THREAD_CORE as unknown as Record<string, unknown>
241
+ )._subscribers as Set<unknown>;
242
+ const before = subscribers.size;
243
+ const unsubscribe = EMPTY_THREAD_CORE.subscribe(() => undefined);
244
+ expect(subscribers.size).toBe(before);
245
+ expect(() => unsubscribe()).not.toThrow();
246
+ });
247
+
248
+ it("rejects waitForUpdate instead of hanging", async () => {
249
+ await expect(
250
+ (EMPTY_THREAD_CORE as unknown as BaseSubscribable).waitForUpdate(),
251
+ ).rejects.toThrow(EMPTY_ERROR);
252
+ });
253
+ });