@assistant-ui/core 0.1.15 → 0.1.16
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/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
- package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
- package/dist/react/primitives/message/MessageParts.d.ts +6 -0
- package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageParts.js +47 -30
- package/dist/react/primitives/message/MessageParts.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +26 -5
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +6 -5
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/runtime/api/bindings.d.ts +1 -0
- package/dist/runtime/api/bindings.d.ts.map +1 -1
- package/dist/runtime/api/composer-runtime.d.ts +4 -2
- package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
- package/dist/runtime/api/composer-runtime.js.map +1 -1
- package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-runtime.js +1 -0
- package/dist/runtime/api/thread-list-runtime.js.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.js +22 -8
- package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
- package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +1 -0
- package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts +3 -0
- package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/remote-thread-state.js.map +1 -1
- package/dist/runtimes/remote-thread-list/types.d.ts +13 -1
- package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
- package/dist/store/scopes/thread-list-item.d.ts +1 -0
- package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/react/adapters/LocalStorageThreadListAdapter.tsx +2 -0
- package/src/react/primitives/message/MessageParts.tsx +71 -44
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +40 -7
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +6 -12
- package/src/runtime/api/bindings.ts +1 -0
- package/src/runtime/api/composer-runtime.ts +4 -2
- package/src/runtime/api/thread-list-runtime.ts +1 -0
- package/src/runtime/base/base-composer-runtime-core.ts +37 -16
- package/src/runtime/interfaces/thread-list-runtime-core.ts +1 -0
- package/src/runtimes/remote-thread-list/remote-thread-state.ts +3 -0
- package/src/runtimes/remote-thread-list/types.ts +13 -1
- package/src/store/scopes/thread-list-item.ts +1 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-custom-metadata.test.ts +123 -0
- package/src/tests/base-composer-runtime-core-addAttachment.test.ts +154 -0
|
@@ -15,14 +15,7 @@ import {
|
|
|
15
15
|
} from "../../runtimes/remote-thread-list/remote-thread-state";
|
|
16
16
|
import type { RemoteThreadListOptions } from "../../runtimes/remote-thread-list/types";
|
|
17
17
|
import { RemoteThreadListHookInstanceManager } from "./RemoteThreadListHookInstanceManager";
|
|
18
|
-
import {
|
|
19
|
-
type ComponentType,
|
|
20
|
-
type FC,
|
|
21
|
-
Fragment,
|
|
22
|
-
type PropsWithChildren,
|
|
23
|
-
useEffect,
|
|
24
|
-
useId,
|
|
25
|
-
} from "react";
|
|
18
|
+
import { type FC, Fragment, useEffect, useId } from "react";
|
|
26
19
|
import { create } from "zustand";
|
|
27
20
|
import { AssistantMessageStream } from "assistant-stream";
|
|
28
21
|
import type { ModelContextProvider } from "../../model-context/types";
|
|
@@ -98,6 +91,7 @@ export class RemoteThreadListThreadListRuntimeCore
|
|
|
98
91
|
externalId: thread.externalId,
|
|
99
92
|
status: thread.status,
|
|
100
93
|
title: thread.title,
|
|
94
|
+
custom: thread.custom,
|
|
101
95
|
initializeTask: Promise.resolve({
|
|
102
96
|
remoteId: thread.remoteId,
|
|
103
97
|
externalId: thread.externalId,
|
|
@@ -150,8 +144,7 @@ export class RemoteThreadListThreadListRuntimeCore
|
|
|
150
144
|
this,
|
|
151
145
|
);
|
|
152
146
|
this.useProvider = create(() => ({
|
|
153
|
-
Provider:
|
|
154
|
-
Fragment) as ComponentType<PropsWithChildren>,
|
|
147
|
+
Provider: options.adapter.unstable_Provider ?? Fragment,
|
|
155
148
|
}));
|
|
156
149
|
this.__internal_setOptions(options);
|
|
157
150
|
this.switchToNewThread();
|
|
@@ -165,8 +158,7 @@ export class RemoteThreadListThreadListRuntimeCore
|
|
|
165
158
|
|
|
166
159
|
this._options = options;
|
|
167
160
|
|
|
168
|
-
const Provider =
|
|
169
|
-
Fragment) as ComponentType<PropsWithChildren>;
|
|
161
|
+
const Provider = options.adapter.unstable_Provider ?? Fragment;
|
|
170
162
|
if (Provider !== this.useProvider.getState().Provider) {
|
|
171
163
|
this.useProvider.setState({ Provider }, true);
|
|
172
164
|
}
|
|
@@ -250,6 +242,7 @@ export class RemoteThreadListThreadListRuntimeCore
|
|
|
250
242
|
externalId: remoteMetadata.externalId,
|
|
251
243
|
status: remoteMetadata.status,
|
|
252
244
|
title: remoteMetadata.title,
|
|
245
|
+
custom: remoteMetadata.custom,
|
|
253
246
|
} as RemoteThreadData,
|
|
254
247
|
};
|
|
255
248
|
|
|
@@ -335,6 +328,7 @@ export class RemoteThreadListThreadListRuntimeCore
|
|
|
335
328
|
remoteId: undefined,
|
|
336
329
|
externalId: undefined,
|
|
337
330
|
title: undefined,
|
|
331
|
+
custom: undefined,
|
|
338
332
|
} satisfies RemoteThreadData,
|
|
339
333
|
},
|
|
340
334
|
});
|
|
@@ -47,4 +47,5 @@ export type ThreadListItemState = {
|
|
|
47
47
|
readonly externalId: string | undefined;
|
|
48
48
|
readonly status: import("../interfaces/thread-list-runtime-core").ThreadListItemStatus;
|
|
49
49
|
readonly title?: string | undefined;
|
|
50
|
+
readonly custom?: Record<string, unknown> | undefined;
|
|
50
51
|
};
|
|
@@ -136,8 +136,10 @@ export type ComposerRuntime = {
|
|
|
136
136
|
/**
|
|
137
137
|
* Add an attachment to the composer. Accepts either a standard File object
|
|
138
138
|
* (processed through the AttachmentAdapter) or a CreateAttachment descriptor
|
|
139
|
-
* for external-source attachments (URLs, API data, CMS references)
|
|
140
|
-
*
|
|
139
|
+
* for external-source attachments (URLs, API data, CMS references). External
|
|
140
|
+
* descriptors bypass the adapter's `add()` step but still respect
|
|
141
|
+
* `adapter.accept` when an adapter is configured; without an adapter they
|
|
142
|
+
* are added as-is.
|
|
141
143
|
* @param fileOrAttachment The file or attachment descriptor to add.
|
|
142
144
|
*/
|
|
143
145
|
addAttachment(fileOrAttachment: File | CreateAttachment): Promise<void>;
|
|
@@ -204,6 +204,27 @@ export abstract class BaseComposerRuntimeCore
|
|
|
204
204
|
|
|
205
205
|
async addAttachment(fileOrAttachment: File | CreateAttachment) {
|
|
206
206
|
if (!(fileOrAttachment instanceof File)) {
|
|
207
|
+
const adapter = this.getAttachmentAdapter();
|
|
208
|
+
if (
|
|
209
|
+
adapter &&
|
|
210
|
+
!fileMatchesAccept(
|
|
211
|
+
{
|
|
212
|
+
name: fileOrAttachment.name,
|
|
213
|
+
type: fileOrAttachment.contentType ?? "",
|
|
214
|
+
},
|
|
215
|
+
adapter.accept,
|
|
216
|
+
)
|
|
217
|
+
) {
|
|
218
|
+
try {
|
|
219
|
+
this._notifyEventSubscribers("attachmentAddError");
|
|
220
|
+
} catch {
|
|
221
|
+
// prevent subscriber errors from masking the original error
|
|
222
|
+
}
|
|
223
|
+
throw new Error(
|
|
224
|
+
`File type ${fileOrAttachment.contentType || "unknown"} is not accepted. Accepted types: ${adapter.accept}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
207
228
|
const a: CompleteAttachment = {
|
|
208
229
|
id: fileOrAttachment.id ?? generateId(),
|
|
209
230
|
type: fileOrAttachment.type ?? "document",
|
|
@@ -213,25 +234,11 @@ export abstract class BaseComposerRuntimeCore
|
|
|
213
234
|
status: { type: "complete" },
|
|
214
235
|
};
|
|
215
236
|
this._attachments = [...this._attachments, a];
|
|
216
|
-
this._notifyEventSubscribers("attachmentAdd");
|
|
217
237
|
this._notifySubscribers();
|
|
238
|
+
this._notifyEventSubscribers("attachmentAdd");
|
|
218
239
|
return;
|
|
219
240
|
}
|
|
220
241
|
|
|
221
|
-
const adapter = this.getAttachmentAdapter();
|
|
222
|
-
if (!adapter) throw new Error("Attachments are not supported");
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
!fileMatchesAccept(
|
|
226
|
-
{ name: fileOrAttachment.name, type: fileOrAttachment.type },
|
|
227
|
-
adapter.accept,
|
|
228
|
-
)
|
|
229
|
-
) {
|
|
230
|
-
throw new Error(
|
|
231
|
-
`File type ${fileOrAttachment.type || "unknown"} is not accepted. Accepted types: ${adapter.accept}`,
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
242
|
const upsertAttachment = (a: PendingAttachment) => {
|
|
236
243
|
const idx = this._attachments.findIndex(
|
|
237
244
|
(attachment) => attachment.id === a.id,
|
|
@@ -251,6 +258,20 @@ export abstract class BaseComposerRuntimeCore
|
|
|
251
258
|
|
|
252
259
|
let lastAttachment: PendingAttachment | undefined;
|
|
253
260
|
try {
|
|
261
|
+
const adapter = this.getAttachmentAdapter();
|
|
262
|
+
if (!adapter) throw new Error("Attachments are not supported");
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
!fileMatchesAccept(
|
|
266
|
+
{ name: fileOrAttachment.name, type: fileOrAttachment.type },
|
|
267
|
+
adapter.accept,
|
|
268
|
+
)
|
|
269
|
+
) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`File type ${fileOrAttachment.type || "unknown"} is not accepted. Accepted types: ${adapter.accept}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
254
275
|
const promiseOrGenerator = adapter.add({ file: fileOrAttachment });
|
|
255
276
|
if (Symbol.asyncIterator in promiseOrGenerator) {
|
|
256
277
|
for await (const r of promiseOrGenerator) {
|
|
@@ -271,7 +292,7 @@ export abstract class BaseComposerRuntimeCore
|
|
|
271
292
|
try {
|
|
272
293
|
this._notifyEventSubscribers("attachmentAddError");
|
|
273
294
|
} catch {
|
|
274
|
-
// prevent subscriber errors from masking the
|
|
295
|
+
// prevent subscriber errors from masking the original error
|
|
275
296
|
}
|
|
276
297
|
throw e;
|
|
277
298
|
}
|
|
@@ -7,6 +7,7 @@ export type RemoteThreadData =
|
|
|
7
7
|
readonly externalId: undefined;
|
|
8
8
|
readonly status: "new";
|
|
9
9
|
readonly title: undefined;
|
|
10
|
+
readonly custom: undefined;
|
|
10
11
|
}
|
|
11
12
|
| {
|
|
12
13
|
readonly id: string;
|
|
@@ -15,6 +16,7 @@ export type RemoteThreadData =
|
|
|
15
16
|
readonly externalId: undefined;
|
|
16
17
|
readonly status: "regular" | "archived";
|
|
17
18
|
readonly title?: string | undefined;
|
|
19
|
+
readonly custom: undefined;
|
|
18
20
|
}
|
|
19
21
|
| {
|
|
20
22
|
readonly id: string;
|
|
@@ -23,6 +25,7 @@ export type RemoteThreadData =
|
|
|
23
25
|
readonly externalId: string | undefined;
|
|
24
26
|
readonly status: "regular" | "archived";
|
|
25
27
|
readonly title?: string | undefined;
|
|
28
|
+
readonly custom?: Record<string, unknown> | undefined;
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
export type THREAD_MAPPING_ID = string & { __brand: "THREAD_MAPPING_ID" };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ComponentType, PropsWithChildren } from "react";
|
|
1
2
|
import type { ThreadMessage } from "../../types/message";
|
|
2
3
|
import type { AssistantRuntime } from "../../runtime/api/assistant-runtime";
|
|
3
4
|
import type { AssistantStream } from "assistant-stream";
|
|
@@ -12,6 +13,7 @@ export type RemoteThreadMetadata = {
|
|
|
12
13
|
readonly remoteId: string;
|
|
13
14
|
readonly externalId?: string | undefined;
|
|
14
15
|
readonly title?: string | undefined;
|
|
16
|
+
readonly custom?: Record<string, unknown> | undefined;
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
export type RemoteThreadListResponse = {
|
|
@@ -32,7 +34,17 @@ export type RemoteThreadListAdapter = {
|
|
|
32
34
|
): Promise<AssistantStream>;
|
|
33
35
|
fetch(threadId: string): Promise<RemoteThreadMetadata>;
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Optional React component wrapped around each active thread. Use it to
|
|
39
|
+
* inject per-thread context such as a history or attachments adapter (see
|
|
40
|
+
* `useCloudThreadListAdapter` for the canonical shape).
|
|
41
|
+
*
|
|
42
|
+
* The Provider must render `children` on its first commit; deferring them
|
|
43
|
+
* behind a loading state, a Suspense boundary, or a `useEffect`-gated render
|
|
44
|
+
* is unsupported and leaves thread context unavailable to downstream
|
|
45
|
+
* consumers. Load data inside an always-mounted child instead.
|
|
46
|
+
*/
|
|
47
|
+
unstable_Provider?: ComponentType<PropsWithChildren> | undefined;
|
|
36
48
|
};
|
|
37
49
|
|
|
38
50
|
export type RemoteThreadListOptions = {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { createCore, makeAdapter } from "./remote-thread-list-test-helpers";
|
|
3
|
+
|
|
4
|
+
describe("RemoteThreadListThreadListRuntimeCore custom metadata", () => {
|
|
5
|
+
it("preserves custom field from list() through to threadItems", async () => {
|
|
6
|
+
const adapter = makeAdapter({
|
|
7
|
+
list: vi.fn(async () => ({
|
|
8
|
+
threads: [
|
|
9
|
+
{
|
|
10
|
+
status: "regular" as const,
|
|
11
|
+
remoteId: "thread-1",
|
|
12
|
+
externalId: "ext-1",
|
|
13
|
+
title: "Test",
|
|
14
|
+
custom: { workspaceId: "ws-1", createdAt: "2026-04-26" },
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
})),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const core = createCore(adapter);
|
|
21
|
+
await core.getLoadThreadsPromise();
|
|
22
|
+
|
|
23
|
+
const item = core.getItemById("thread-1");
|
|
24
|
+
expect(item?.custom).toEqual({
|
|
25
|
+
workspaceId: "ws-1",
|
|
26
|
+
createdAt: "2026-04-26",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("preserves custom field from fetch() through switchToThread", async () => {
|
|
31
|
+
const adapter = makeAdapter({
|
|
32
|
+
fetch: vi.fn(async (id: string) => ({
|
|
33
|
+
status: "regular" as const,
|
|
34
|
+
remoteId: id,
|
|
35
|
+
externalId: id,
|
|
36
|
+
title: "Test",
|
|
37
|
+
custom: { ownerId: "user-42" },
|
|
38
|
+
})),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const core = createCore(adapter);
|
|
42
|
+
await core.switchToThread("thread-2");
|
|
43
|
+
|
|
44
|
+
const item = core.getItemById("thread-2");
|
|
45
|
+
expect(item?.custom).toEqual({ ownerId: "user-42" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("leaves custom undefined when adapter omits it", async () => {
|
|
49
|
+
const adapter = makeAdapter({
|
|
50
|
+
list: vi.fn(async () => ({
|
|
51
|
+
threads: [
|
|
52
|
+
{
|
|
53
|
+
status: "regular" as const,
|
|
54
|
+
remoteId: "thread-3",
|
|
55
|
+
externalId: "ext-3",
|
|
56
|
+
title: "Test",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
})),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const core = createCore(adapter);
|
|
63
|
+
await core.getLoadThreadsPromise();
|
|
64
|
+
|
|
65
|
+
const item = core.getItemById("thread-3");
|
|
66
|
+
expect(item?.custom).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("preserves custom across rename optimistic update", async () => {
|
|
70
|
+
const adapter = makeAdapter({
|
|
71
|
+
list: vi.fn(async () => ({
|
|
72
|
+
threads: [
|
|
73
|
+
{
|
|
74
|
+
status: "regular" as const,
|
|
75
|
+
remoteId: "thread-4",
|
|
76
|
+
externalId: "ext-4",
|
|
77
|
+
title: "Old",
|
|
78
|
+
custom: { tag: "important" },
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
})),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const core = createCore(adapter);
|
|
85
|
+
await core.getLoadThreadsPromise();
|
|
86
|
+
await core.rename("thread-4", "New");
|
|
87
|
+
|
|
88
|
+
const item = core.getItemById("thread-4");
|
|
89
|
+
expect(item?.title).toBe("New");
|
|
90
|
+
expect(item?.custom).toEqual({ tag: "important" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("preserves custom across archive and unarchive optimistic updates", async () => {
|
|
94
|
+
const adapter = makeAdapter({
|
|
95
|
+
list: vi.fn(async () => ({
|
|
96
|
+
threads: [
|
|
97
|
+
{
|
|
98
|
+
status: "regular" as const,
|
|
99
|
+
remoteId: "thread-5",
|
|
100
|
+
externalId: "ext-5",
|
|
101
|
+
title: "Test",
|
|
102
|
+
custom: { workspaceId: "ws-1" },
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
})),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const core = createCore(adapter);
|
|
109
|
+
await core.getLoadThreadsPromise();
|
|
110
|
+
|
|
111
|
+
await core.archive("thread-5");
|
|
112
|
+
expect(core.getItemById("thread-5")?.status).toBe("archived");
|
|
113
|
+
expect(core.getItemById("thread-5")?.custom).toEqual({
|
|
114
|
+
workspaceId: "ws-1",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await core.unarchive("thread-5");
|
|
118
|
+
expect(core.getItemById("thread-5")?.status).toBe("regular");
|
|
119
|
+
expect(core.getItemById("thread-5")?.custom).toEqual({
|
|
120
|
+
workspaceId: "ws-1",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { DefaultThreadComposerRuntimeCore } from "../runtime/base/default-thread-composer-runtime-core";
|
|
3
|
+
import type { AttachmentAdapter } from "../adapters/attachment";
|
|
4
|
+
import type { ThreadRuntimeCore } from "../runtime/interfaces/thread-runtime-core";
|
|
5
|
+
import type { PendingAttachment } from "../types/attachment";
|
|
6
|
+
|
|
7
|
+
const makeAdapter = (
|
|
8
|
+
overrides: Partial<AttachmentAdapter> = {},
|
|
9
|
+
): AttachmentAdapter => ({
|
|
10
|
+
accept: "image/*",
|
|
11
|
+
add: async ({ file }: { file: File }): Promise<PendingAttachment> => ({
|
|
12
|
+
id: "att-1",
|
|
13
|
+
type: "image",
|
|
14
|
+
name: file.name,
|
|
15
|
+
contentType: file.type,
|
|
16
|
+
file,
|
|
17
|
+
status: { type: "running", reason: "uploading", progress: 0 },
|
|
18
|
+
}),
|
|
19
|
+
remove: async () => {},
|
|
20
|
+
send: async (a) => ({ ...a, status: { type: "complete" }, content: [] }),
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const makeComposer = (adapter?: AttachmentAdapter) => {
|
|
25
|
+
const runtime = {
|
|
26
|
+
append: vi.fn(),
|
|
27
|
+
cancelRun: vi.fn(),
|
|
28
|
+
subscribe: vi.fn(() => () => {}),
|
|
29
|
+
capabilities: { cancel: false },
|
|
30
|
+
messages: [],
|
|
31
|
+
adapters: adapter ? { attachments: adapter } : undefined,
|
|
32
|
+
} as unknown as Omit<ThreadRuntimeCore, "composer"> & {
|
|
33
|
+
adapters?: { attachments?: AttachmentAdapter };
|
|
34
|
+
};
|
|
35
|
+
return new DefaultThreadComposerRuntimeCore(runtime);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("BaseComposerRuntimeCore.addAttachment error events", () => {
|
|
39
|
+
it("emits attachmentAddError when no adapter is configured", async () => {
|
|
40
|
+
const composer = makeComposer();
|
|
41
|
+
const onError = vi.fn();
|
|
42
|
+
const onAdd = vi.fn();
|
|
43
|
+
composer.unstable_on("attachmentAddError", onError);
|
|
44
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
45
|
+
|
|
46
|
+
await expect(
|
|
47
|
+
composer.addAttachment(new File(["x"], "f.txt", { type: "text/plain" })),
|
|
48
|
+
).rejects.toThrow("Attachments are not supported");
|
|
49
|
+
|
|
50
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("emits attachmentAddError when file type is rejected by adapter.accept", async () => {
|
|
55
|
+
const composer = makeComposer(makeAdapter());
|
|
56
|
+
const onError = vi.fn();
|
|
57
|
+
const onAdd = vi.fn();
|
|
58
|
+
composer.unstable_on("attachmentAddError", onError);
|
|
59
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
60
|
+
|
|
61
|
+
await expect(
|
|
62
|
+
composer.addAttachment(new File(["x"], "f.txt", { type: "text/plain" })),
|
|
63
|
+
).rejects.toThrow(/File type text\/plain is not accepted/);
|
|
64
|
+
|
|
65
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("emits attachmentAddError when adapter.add throws", async () => {
|
|
70
|
+
const composer = makeComposer(
|
|
71
|
+
makeAdapter({
|
|
72
|
+
add: async () => {
|
|
73
|
+
throw new Error("upload failed");
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
const onError = vi.fn();
|
|
78
|
+
const onAdd = vi.fn();
|
|
79
|
+
composer.unstable_on("attachmentAddError", onError);
|
|
80
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
81
|
+
|
|
82
|
+
await expect(
|
|
83
|
+
composer.addAttachment(new File(["x"], "f.png", { type: "image/png" })),
|
|
84
|
+
).rejects.toThrow("upload failed");
|
|
85
|
+
|
|
86
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("emits attachmentAdd on successful add", async () => {
|
|
91
|
+
const composer = makeComposer(makeAdapter());
|
|
92
|
+
const onError = vi.fn();
|
|
93
|
+
const onAdd = vi.fn();
|
|
94
|
+
composer.unstable_on("attachmentAddError", onError);
|
|
95
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
96
|
+
|
|
97
|
+
await composer.addAttachment(
|
|
98
|
+
new File(["x"], "f.png", { type: "image/png" }),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(onAdd).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(onError).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does not let subscriber errors mask the original throw", async () => {
|
|
106
|
+
const composer = makeComposer(makeAdapter());
|
|
107
|
+
composer.unstable_on("attachmentAddError", () => {
|
|
108
|
+
throw new Error("subscriber boom");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
composer.addAttachment(new File(["x"], "f.txt", { type: "text/plain" })),
|
|
113
|
+
).rejects.toThrow(/File type text\/plain is not accepted/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("emits attachmentAddError when async generator adapter throws mid-iteration", async () => {
|
|
117
|
+
const composer = makeComposer(
|
|
118
|
+
makeAdapter({
|
|
119
|
+
async *add({ file }: { file: File }) {
|
|
120
|
+
yield {
|
|
121
|
+
id: "att-1",
|
|
122
|
+
type: "image" as const,
|
|
123
|
+
name: file.name,
|
|
124
|
+
contentType: file.type,
|
|
125
|
+
file,
|
|
126
|
+
status: {
|
|
127
|
+
type: "running" as const,
|
|
128
|
+
reason: "uploading" as const,
|
|
129
|
+
progress: 0,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
throw new Error("network error");
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const onError = vi.fn();
|
|
137
|
+
const onAdd = vi.fn();
|
|
138
|
+
composer.unstable_on("attachmentAddError", onError);
|
|
139
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
140
|
+
|
|
141
|
+
await expect(
|
|
142
|
+
composer.addAttachment(new File(["x"], "f.png", { type: "image/png" })),
|
|
143
|
+
).rejects.toThrow("network error");
|
|
144
|
+
|
|
145
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
147
|
+
expect(composer.attachments).toHaveLength(1);
|
|
148
|
+
const att = composer.attachments[0]!;
|
|
149
|
+
expect(att.status.type).toBe("incomplete");
|
|
150
|
+
if (att.status.type === "incomplete") {
|
|
151
|
+
expect(att.status.reason).toBe("error");
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|