@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.
- package/dist/adapters/voice.js +11 -0
- package/dist/adapters/voice.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.js +2 -1
- package/dist/model-context/frame/host.d.ts +1 -1
- package/dist/model-context/frame/host.d.ts.map +1 -1
- package/dist/model-context/frame/host.js +17 -3
- package/dist/model-context/frame/host.js.map +1 -1
- package/dist/model-context/frame/provider.d.ts +4 -0
- package/dist/model-context/frame/provider.d.ts.map +1 -1
- package/dist/model-context/frame/provider.js +50 -3
- package/dist/model-context/frame/provider.js.map +1 -1
- package/dist/model-context/frame/types.d.ts +4 -1
- package/dist/model-context/frame/types.d.ts.map +1 -1
- package/dist/model-context/frame/types.js.map +1 -1
- package/dist/react/client/RemoteThreadList.d.ts +1 -1
- package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
- package/dist/react/client/RemoteThreadList.js +95 -23
- package/dist/react/client/RemoteThreadList.js.map +1 -1
- package/dist/react/primitive-hooks/useActionBarCopy.js +7 -1
- package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts +3 -0
- package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsEmpty.js +3 -0
- package/dist/react/primitive-hooks/useThreadIsEmpty.js.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsRunning.d.ts +3 -0
- package/dist/react/primitive-hooks/useThreadIsRunning.d.ts.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsRunning.js +3 -0
- package/dist/react/primitive-hooks/useThreadIsRunning.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -0
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +6 -0
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -0
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +192 -50
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadResource.js +2 -0
- package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
- package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js +1 -1
- package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +38 -5
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
- package/dist/runtime/utils/message-repository.d.ts.map +1 -1
- package/dist/runtime/utils/message-repository.js +27 -18
- package/dist/runtime/utils/message-repository.js.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -0
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts +102 -0
- package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts.map +1 -0
- package/dist/runtimes/inert/InertThreadRuntimeCore.js +156 -0
- package/dist/runtimes/inert/InertThreadRuntimeCore.js.map +1 -0
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.js +25 -6
- package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +12 -60
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +10 -145
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
- package/dist/runtimes/remote-thread-list/adapter-changed.d.ts +9 -0
- package/dist/runtimes/remote-thread-list/adapter-changed.d.ts.map +1 -0
- package/dist/runtimes/remote-thread-list/adapter-changed.js +13 -0
- package/dist/runtimes/remote-thread-list/adapter-changed.js.map +1 -0
- package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/empty-thread-core.js +16 -155
- package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
- package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +2 -0
- package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/optimistic-state.js +11 -0
- package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
- package/dist/runtimes/remote-thread-list/types.d.ts +3 -0
- package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
- package/dist/store/clients/external-thread.js +1 -1
- package/dist/store/clients/external-thread.js.map +1 -1
- package/dist/store/runtime-clients/handle-runtime-action.d.ts.map +1 -1
- package/dist/store/runtime-clients/handle-runtime-action.js +2 -0
- package/dist/store/runtime-clients/handle-runtime-action.js.map +1 -1
- package/dist/types/message.d.ts +1 -1
- package/dist/types/message.js.map +1 -1
- package/dist/utils/silent-runtime-action.d.ts +6 -0
- package/dist/utils/silent-runtime-action.d.ts.map +1 -0
- package/dist/utils/silent-runtime-action.js +7 -0
- package/dist/utils/silent-runtime-action.js.map +1 -0
- package/package.json +4 -4
- package/src/adapters/voice.test.ts +66 -1
- package/src/adapters/voice.ts +8 -0
- package/src/internal.ts +1 -0
- package/src/model-context/frame/host.test.ts +64 -5
- package/src/model-context/frame/host.ts +16 -3
- package/src/model-context/frame/provider.test.ts +347 -20
- package/src/model-context/frame/provider.ts +58 -2
- package/src/model-context/frame/types.ts +5 -0
- package/src/react/client/RemoteThreadList.test.ts +196 -0
- package/src/react/client/RemoteThreadList.ts +93 -24
- package/src/react/primitive-hooks/useActionBarCopy.test.ts +15 -0
- package/src/react/primitive-hooks/useActionBarCopy.ts +13 -3
- package/src/react/primitive-hooks/useThreadIsEmpty.ts +3 -0
- package/src/react/primitive-hooks/useThreadIsRunning.ts +3 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +12 -0
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.republish.test.ts +87 -0
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +268 -63
- package/src/react/runtimes/RemoteThreadResource.ts +2 -0
- package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.test.tsx +58 -0
- package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.ts +3 -1
- package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +98 -3
- package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +68 -6
- package/src/react/runtimes/useLocalRuntime.test.tsx +5 -5
- package/src/runtime/utils/message-repository.ts +26 -18
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +4 -0
- package/src/runtimes/inert/InertThreadRuntimeCore.test.ts +253 -0
- package/src/runtimes/inert/InertThreadRuntimeCore.ts +232 -0
- package/src/runtimes/local/local-thread-runtime-core.test.ts +319 -0
- package/src/runtimes/local/local-thread-runtime-core.ts +38 -8
- package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +17 -209
- package/src/runtimes/remote-thread-list/adapter-changed.ts +10 -0
- package/src/runtimes/remote-thread-list/empty-thread-core.ts +28 -221
- package/src/runtimes/remote-thread-list/optimistic-state.test.ts +18 -0
- package/src/runtimes/remote-thread-list/optimistic-state.ts +13 -0
- package/src/runtimes/remote-thread-list/types.ts +4 -0
- package/src/store/clients/external-thread.ts +1 -1
- package/src/store/runtime-clients/handle-runtime-action.test.ts +40 -0
- package/src/store/runtime-clients/handle-runtime-action.ts +3 -0
- package/src/tests/MessageRepository.test.ts +48 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-errors.test.ts +2 -2
- package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +8 -6
- package/src/tests/empty-thread-core.test.ts +6 -0
- package/src/tests/external-store-thread-runtime-core-adapter.test.ts +20 -0
- package/src/tests/external-thread-isEmpty.test.tsx +100 -0
- package/src/tests/remote-thread-list-adapter-switch.test.ts +383 -0
- package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +67 -0
- package/src/types/message.ts +1 -1
- 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 && {
|
|
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("
|
|
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).
|
|
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).
|
|
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 {
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
125
|
-
?
|
|
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).
|
|
86
|
+
expect(firstCloud.threads.list).toHaveBeenCalledTimes(2);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
rerender(<App cloud={firstCloud} label="second" />);
|
|
90
|
-
expect(firstCloud.threads.list).
|
|
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).
|
|
94
|
+
expect(secondCloud.threads.list).toHaveBeenCalledTimes(2);
|
|
95
95
|
});
|
|
96
|
-
expect(firstCloud.threads.list).
|
|
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).
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
});
|