@assistant-ui/core 0.3.3 → 0.3.4
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/internal.d.ts +4 -2
- package/dist/internal.js +4 -2
- package/dist/react/primitive-hooks/useSuggestionTrigger.js +29 -25
- package/dist/react/primitive-hooks/useSuggestionTrigger.js.map +1 -1
- package/dist/react/primitives/message/MessageParts.d.ts +6 -1
- package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageParts.js.map +1 -1
- package/dist/react/providers/ReadonlyThreadProvider.d.ts.map +1 -1
- package/dist/react/providers/ReadonlyThreadProvider.js +1 -0
- package/dist/react/providers/ReadonlyThreadProvider.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +67 -0
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +76 -15
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +4 -0
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +21 -0
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/react/runtimes/cloud/auiV0.d.ts +2 -0
- package/dist/react/runtimes/cloud/auiV0.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/auiV0.js +6 -4
- package/dist/react/runtimes/cloud/auiV0.js.map +1 -1
- package/dist/react/types/MessagePartComponentTypes.d.ts +8 -0
- package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
- package/dist/runtime/api/bindings.d.ts +5 -0
- package/dist/runtime/api/bindings.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-runtime.d.ts +14 -1
- package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-runtime.js +6 -1
- package/dist/runtime/api/thread-list-runtime.js.map +1 -1
- package/dist/runtime/api/thread-runtime.d.ts +7 -1
- package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-runtime.js +11 -3
- package/dist/runtime/api/thread-runtime.js.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts +3 -0
- package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.js +54 -12
- package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
- package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +10 -0
- package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtime/interfaces/thread-runtime-core.d.ts +14 -0
- package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-adapter.d.ts +9 -0
- package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts +1 -0
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js +4 -0
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +1 -0
- 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 +6 -0
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.d.ts +1 -0
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.js +1 -0
- package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -0
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +1 -0
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
- package/dist/runtimes/remote-thread-list/empty-thread-core.js +1 -0
- package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts +1 -0
- package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.js +66 -45
- package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
- package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-runtime-client.js +91 -75
- package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
- package/dist/store/scopes/thread-list-item.d.ts +7 -0
- package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
- package/dist/store/scopes/threads.d.ts +1 -0
- package/dist/store/scopes/threads.d.ts.map +1 -1
- package/dist/types/message.d.ts +11 -0
- package/dist/types/message.d.ts.map +1 -1
- package/dist/types/message.js.map +1 -1
- package/dist/utils/data-url.d.ts +14 -1
- package/dist/utils/data-url.d.ts.map +1 -1
- package/dist/utils/data-url.js +23 -1
- package/dist/utils/data-url.js.map +1 -1
- package/dist/utils/image-media-type.d.ts +13 -0
- package/dist/utils/image-media-type.d.ts.map +1 -0
- package/dist/utils/image-media-type.js +141 -0
- package/dist/utils/image-media-type.js.map +1 -0
- package/dist/utils/wire-media.d.ts +35 -0
- package/dist/utils/wire-media.d.ts.map +1 -0
- package/dist/utils/wire-media.js +55 -0
- package/dist/utils/wire-media.js.map +1 -0
- package/package.json +3 -3
- package/src/internal.ts +12 -1
- package/src/react/primitive-hooks/useSuggestionTrigger.test.ts +148 -0
- package/src/react/primitive-hooks/useSuggestionTrigger.ts +10 -4
- package/src/react/primitives/message/MessageParts.tsx +6 -1
- package/src/react/providers/ReadonlyThreadProvider.tsx +1 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.running.test.tsx +157 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +160 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +135 -32
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +37 -0
- package/src/react/runtimes/cloud/auiV0.ts +8 -2
- package/src/react/types/MessagePartComponentTypes.ts +8 -0
- package/src/runtime/api/bindings.ts +5 -0
- package/src/runtime/api/thread-list-runtime.ts +22 -1
- package/src/runtime/api/thread-runtime.ts +15 -6
- package/src/runtime/base/base-composer-runtime-core.ts +72 -19
- package/src/runtime/interfaces/thread-list-runtime-core.ts +11 -0
- package/src/runtime/interfaces/thread-runtime-core.ts +15 -0
- package/src/runtimes/external-store/external-store-adapter.ts +9 -0
- package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +9 -0
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +8 -0
- package/src/runtimes/local/local-thread-runtime-core.ts +1 -0
- package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +1 -0
- package/src/runtimes/remote-thread-list/empty-thread-core.ts +1 -0
- package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -2
- package/src/store/runtime-clients/thread-list-runtime-client.ts +13 -1
- package/src/store/scopes/thread-list-item.ts +7 -0
- package/src/store/scopes/threads.ts +1 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-reloadMainThread.test.ts +257 -0
- package/src/tests/auiV0Encode.test.ts +125 -1
- package/src/tests/base-composer-runtime-core-addAttachment.test.ts +296 -0
- package/src/tests/event-subscription-listener-errors.test.ts +1 -0
- package/src/tests/external-store-thread-list-runtime-core.test.ts +39 -1
- package/src/types/message.ts +11 -0
- package/src/utils/data-url.ts +24 -0
- package/src/utils/image-media-type.test.ts +96 -0
- package/src/utils/image-media-type.ts +118 -0
- package/src/utils/wire-media.test.ts +96 -0
- package/src/utils/wire-media.ts +70 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { auiV0Decode, auiV0Encode } from "../react/runtimes/cloud/auiV0";
|
|
3
3
|
|
|
4
4
|
describe("auiV0Encode", () => {
|
|
@@ -127,6 +127,61 @@ describe("auiV0Encode", () => {
|
|
|
127
127
|
]);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
it("preserves file sourceType in the core cloud encoder", () => {
|
|
131
|
+
const encoded = auiV0Encode({
|
|
132
|
+
id: "m1",
|
|
133
|
+
createdAt: new Date("2026-03-15T00:00:00.000Z"),
|
|
134
|
+
role: "user",
|
|
135
|
+
metadata: { custom: {} },
|
|
136
|
+
content: [
|
|
137
|
+
{
|
|
138
|
+
type: "file",
|
|
139
|
+
data: "file-abc123",
|
|
140
|
+
mimeType: "application/pdf",
|
|
141
|
+
filename: "a.pdf",
|
|
142
|
+
sourceType: "id",
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
attachments: [
|
|
146
|
+
{
|
|
147
|
+
id: "att-1",
|
|
148
|
+
type: "document",
|
|
149
|
+
name: "a.pdf",
|
|
150
|
+
contentType: "application/pdf",
|
|
151
|
+
status: { type: "complete" },
|
|
152
|
+
content: [
|
|
153
|
+
{
|
|
154
|
+
type: "file",
|
|
155
|
+
data: "file-abc123",
|
|
156
|
+
mimeType: "application/pdf",
|
|
157
|
+
filename: "a.pdf",
|
|
158
|
+
sourceType: "id",
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(encoded.content).toEqual([
|
|
166
|
+
{
|
|
167
|
+
type: "file",
|
|
168
|
+
data: "file-abc123",
|
|
169
|
+
mimeType: "application/pdf",
|
|
170
|
+
filename: "a.pdf",
|
|
171
|
+
sourceType: "id",
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
expect(encoded.attachments?.[0]?.content).toEqual([
|
|
175
|
+
{
|
|
176
|
+
type: "file",
|
|
177
|
+
data: "file-abc123",
|
|
178
|
+
mimeType: "application/pdf",
|
|
179
|
+
filename: "a.pdf",
|
|
180
|
+
sourceType: "id",
|
|
181
|
+
},
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
|
|
130
185
|
it("omits missing attachment contentType in the core cloud encoder", () => {
|
|
131
186
|
const encoded = auiV0Encode({
|
|
132
187
|
id: "m1",
|
|
@@ -352,4 +407,73 @@ describe("auiV0Decode", () => {
|
|
|
352
407
|
},
|
|
353
408
|
]);
|
|
354
409
|
});
|
|
410
|
+
|
|
411
|
+
const toolCallMessage = (
|
|
412
|
+
part: Record<string, unknown>,
|
|
413
|
+
): Parameters<typeof auiV0Encode>[0] => ({
|
|
414
|
+
id: "m1",
|
|
415
|
+
createdAt: new Date("2026-03-15T00:00:00.000Z"),
|
|
416
|
+
role: "assistant",
|
|
417
|
+
status: { type: "complete", reason: "stop" },
|
|
418
|
+
metadata: {
|
|
419
|
+
unstable_state: null,
|
|
420
|
+
unstable_annotations: [],
|
|
421
|
+
unstable_data: [],
|
|
422
|
+
steps: [],
|
|
423
|
+
custom: {},
|
|
424
|
+
},
|
|
425
|
+
content: [
|
|
426
|
+
{
|
|
427
|
+
type: "tool-call",
|
|
428
|
+
toolCallId: "call-1",
|
|
429
|
+
toolName: "check_flag",
|
|
430
|
+
args: {},
|
|
431
|
+
argsText: "{}",
|
|
432
|
+
...part,
|
|
433
|
+
},
|
|
434
|
+
],
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it.each([
|
|
438
|
+
["false", false],
|
|
439
|
+
["zero", 0],
|
|
440
|
+
["an empty string", ""],
|
|
441
|
+
["null", null],
|
|
442
|
+
])("preserves %s as a tool-call result", (_label, result) => {
|
|
443
|
+
const encoded = auiV0Encode(toolCallMessage({ result }));
|
|
444
|
+
|
|
445
|
+
const toolCall = encoded.content.find((p) => p.type === "tool-call");
|
|
446
|
+
expect(toolCall).toHaveProperty("result", result);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("omits the result of a tool call that has not settled", () => {
|
|
450
|
+
const encoded = auiV0Encode(toolCallMessage({}));
|
|
451
|
+
|
|
452
|
+
const toolCall = encoded.content.find((p) => p.type === "tool-call");
|
|
453
|
+
expect(toolCall).not.toHaveProperty("result");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("does not warn about a tool call that has not settled", () => {
|
|
457
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
458
|
+
try {
|
|
459
|
+
auiV0Encode(toolCallMessage({}));
|
|
460
|
+
expect(warn).not.toHaveBeenCalled();
|
|
461
|
+
} finally {
|
|
462
|
+
warn.mockRestore();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("carries a falsy tool-call result through a decode round trip", () => {
|
|
467
|
+
const encoded = auiV0Encode(toolCallMessage({ result: false }));
|
|
468
|
+
const { message } = auiV0Decode({
|
|
469
|
+
id: "m1",
|
|
470
|
+
parent_id: null,
|
|
471
|
+
format: "aui/v0",
|
|
472
|
+
content: encoded,
|
|
473
|
+
created_at: new Date("2026-03-15T00:00:00.000Z"),
|
|
474
|
+
} as unknown as Parameters<typeof auiV0Decode>[0]);
|
|
475
|
+
|
|
476
|
+
const toolCall = message.content.find((p) => p.type === "tool-call");
|
|
477
|
+
expect(toolCall).toHaveProperty("result", false);
|
|
478
|
+
});
|
|
355
479
|
});
|
|
@@ -38,6 +38,22 @@ const makeComposer = (adapter?: AttachmentAdapter) => {
|
|
|
38
38
|
return new DefaultThreadComposerRuntimeCore(runtime);
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
const makeUploadingAttachment = (
|
|
42
|
+
file: File,
|
|
43
|
+
id: string,
|
|
44
|
+
): PendingAttachment => ({
|
|
45
|
+
id,
|
|
46
|
+
type: "image",
|
|
47
|
+
name: file.name,
|
|
48
|
+
contentType: file.type,
|
|
49
|
+
file,
|
|
50
|
+
status: {
|
|
51
|
+
type: "running",
|
|
52
|
+
reason: "uploading",
|
|
53
|
+
progress: 0,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
41
57
|
describe("BaseComposerRuntimeCore.addAttachment error events", () => {
|
|
42
58
|
it("emits attachmentAddError when no adapter is configured", async () => {
|
|
43
59
|
const composer = makeComposer();
|
|
@@ -128,6 +144,286 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
|
|
|
128
144
|
expect(onError).not.toHaveBeenCalled();
|
|
129
145
|
});
|
|
130
146
|
|
|
147
|
+
it("does not restore an attachment removed while add is still running", async () => {
|
|
148
|
+
let finishUpload!: () => void;
|
|
149
|
+
const uploadPending = new Promise<void>((resolve) => {
|
|
150
|
+
finishUpload = resolve;
|
|
151
|
+
});
|
|
152
|
+
let finishRemoval!: () => void;
|
|
153
|
+
const removalPending = new Promise<void>((resolve) => {
|
|
154
|
+
finishRemoval = resolve;
|
|
155
|
+
});
|
|
156
|
+
const remove = vi.fn(() => removalPending);
|
|
157
|
+
const continueUpload = vi.fn();
|
|
158
|
+
const cleanUpUpload = vi.fn();
|
|
159
|
+
const composer = makeComposer(
|
|
160
|
+
makeAdapter({
|
|
161
|
+
remove,
|
|
162
|
+
async *add({ file }: { file: File }) {
|
|
163
|
+
const attachment = makeUploadingAttachment(file, "att-1");
|
|
164
|
+
try {
|
|
165
|
+
yield attachment;
|
|
166
|
+
await uploadPending;
|
|
167
|
+
yield {
|
|
168
|
+
...attachment,
|
|
169
|
+
status: { type: "requires-action", reason: "composer-send" },
|
|
170
|
+
};
|
|
171
|
+
continueUpload();
|
|
172
|
+
yield attachment;
|
|
173
|
+
} finally {
|
|
174
|
+
cleanUpUpload();
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
const onAdd = vi.fn();
|
|
180
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
181
|
+
|
|
182
|
+
const addTask = composer.addAttachment(
|
|
183
|
+
new File(["x"], "f.png", { type: "image/png" }),
|
|
184
|
+
);
|
|
185
|
+
await vi.waitFor(() => {
|
|
186
|
+
expect(composer.attachments).toHaveLength(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const removeTask = composer.removeAttachment("att-1");
|
|
190
|
+
finishUpload();
|
|
191
|
+
await addTask;
|
|
192
|
+
|
|
193
|
+
expect(remove).toHaveBeenCalledTimes(1);
|
|
194
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
195
|
+
expect(continueUpload).not.toHaveBeenCalled();
|
|
196
|
+
expect(cleanUpUpload).toHaveBeenCalledTimes(1);
|
|
197
|
+
|
|
198
|
+
finishRemoval();
|
|
199
|
+
await removeTask;
|
|
200
|
+
expect(composer.attachments).toHaveLength(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("settles a pending attachment when adapter removal fails", async () => {
|
|
204
|
+
let finishUpload!: () => void;
|
|
205
|
+
const uploadPending = new Promise<void>((resolve) => {
|
|
206
|
+
finishUpload = resolve;
|
|
207
|
+
});
|
|
208
|
+
const cleanUpUpload = vi.fn();
|
|
209
|
+
const composer = makeComposer(
|
|
210
|
+
makeAdapter({
|
|
211
|
+
remove: vi.fn().mockRejectedValue(new Error("remove failed")),
|
|
212
|
+
async *add({ file }: { file: File }) {
|
|
213
|
+
const attachment = makeUploadingAttachment(file, "att-1");
|
|
214
|
+
try {
|
|
215
|
+
yield attachment;
|
|
216
|
+
await uploadPending;
|
|
217
|
+
yield {
|
|
218
|
+
...attachment,
|
|
219
|
+
status: { type: "requires-action", reason: "composer-send" },
|
|
220
|
+
};
|
|
221
|
+
} finally {
|
|
222
|
+
cleanUpUpload();
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
const onAdd = vi.fn();
|
|
228
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
229
|
+
|
|
230
|
+
const addTask = composer.addAttachment(
|
|
231
|
+
new File(["x"], "f.png", { type: "image/png" }),
|
|
232
|
+
);
|
|
233
|
+
await vi.waitFor(() => {
|
|
234
|
+
expect(composer.attachments).toHaveLength(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await expect(composer.removeAttachment("att-1")).rejects.toThrow(
|
|
238
|
+
"remove failed",
|
|
239
|
+
);
|
|
240
|
+
expect(composer.attachments[0]?.status).toEqual({
|
|
241
|
+
type: "incomplete",
|
|
242
|
+
reason: "error",
|
|
243
|
+
message: "remove failed",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
finishUpload();
|
|
247
|
+
await addTask;
|
|
248
|
+
|
|
249
|
+
expect(composer.attachments[0]?.status.type).toBe("incomplete");
|
|
250
|
+
expect(cleanUpUpload).toHaveBeenCalledTimes(1);
|
|
251
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("keeps a concurrent add registered before its first yield", async () => {
|
|
255
|
+
let startSlowUpload!: () => void;
|
|
256
|
+
const slowUploadStarted = new Promise<void>((resolve) => {
|
|
257
|
+
startSlowUpload = resolve;
|
|
258
|
+
});
|
|
259
|
+
let finishSlowUpload!: () => void;
|
|
260
|
+
const slowUploadPending = new Promise<void>((resolve) => {
|
|
261
|
+
finishSlowUpload = resolve;
|
|
262
|
+
});
|
|
263
|
+
const composer = makeComposer(
|
|
264
|
+
makeAdapter({
|
|
265
|
+
async *add({ file }: { file: File }) {
|
|
266
|
+
const attachment = makeUploadingAttachment(file, `att-${file.name}`);
|
|
267
|
+
if (file.name === "slow.png") await slowUploadStarted;
|
|
268
|
+
yield attachment;
|
|
269
|
+
if (file.name === "slow.png") await slowUploadPending;
|
|
270
|
+
yield {
|
|
271
|
+
...attachment,
|
|
272
|
+
status: { type: "requires-action", reason: "composer-send" },
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
const onAdd = vi.fn();
|
|
278
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
279
|
+
|
|
280
|
+
const slowAddTask = composer.addAttachment(
|
|
281
|
+
new File(["slow"], "slow.png", { type: "image/png" }),
|
|
282
|
+
);
|
|
283
|
+
await composer.addAttachment(
|
|
284
|
+
new File(["fast"], "fast.png", { type: "image/png" }),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
startSlowUpload();
|
|
288
|
+
await vi.waitFor(() => {
|
|
289
|
+
expect(
|
|
290
|
+
composer.attachments.some(
|
|
291
|
+
(attachment) => attachment.id === "att-slow.png",
|
|
292
|
+
),
|
|
293
|
+
).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
await composer.removeAttachment("att-slow.png");
|
|
296
|
+
finishSlowUpload();
|
|
297
|
+
await slowAddTask;
|
|
298
|
+
|
|
299
|
+
expect(
|
|
300
|
+
composer.attachments.some(
|
|
301
|
+
(attachment) => attachment.id === "att-slow.png",
|
|
302
|
+
),
|
|
303
|
+
).toBe(false);
|
|
304
|
+
expect(onAdd).toHaveBeenCalledTimes(1);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("cancels an add cleared before its first yield", async () => {
|
|
308
|
+
let startUpload!: () => void;
|
|
309
|
+
const uploadPending = new Promise<void>((resolve) => {
|
|
310
|
+
startUpload = resolve;
|
|
311
|
+
});
|
|
312
|
+
const composer = makeComposer(
|
|
313
|
+
makeAdapter({
|
|
314
|
+
async *add({ file }: { file: File }) {
|
|
315
|
+
await uploadPending;
|
|
316
|
+
yield makeUploadingAttachment(file, "att-1");
|
|
317
|
+
},
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
const onAdd = vi.fn();
|
|
321
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
322
|
+
|
|
323
|
+
const addTask = composer.addAttachment(
|
|
324
|
+
new File(["x"], "f.png", { type: "image/png" }),
|
|
325
|
+
);
|
|
326
|
+
await composer.clearAttachments();
|
|
327
|
+
startUpload();
|
|
328
|
+
await addTask;
|
|
329
|
+
|
|
330
|
+
expect(composer.attachments).toHaveLength(0);
|
|
331
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("cancels a promise add cleared before it resolves", async () => {
|
|
335
|
+
let finishUpload!: () => void;
|
|
336
|
+
const uploadPending = new Promise<void>((resolve) => {
|
|
337
|
+
finishUpload = resolve;
|
|
338
|
+
});
|
|
339
|
+
const composer = makeComposer(
|
|
340
|
+
makeAdapter({
|
|
341
|
+
add: async ({ file }: { file: File }) => {
|
|
342
|
+
await uploadPending;
|
|
343
|
+
return makeUploadingAttachment(file, "att-1");
|
|
344
|
+
},
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
const onAdd = vi.fn();
|
|
348
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
349
|
+
|
|
350
|
+
const addTask = composer.addAttachment(
|
|
351
|
+
new File(["x"], "f.png", { type: "image/png" }),
|
|
352
|
+
);
|
|
353
|
+
await composer.clearAttachments();
|
|
354
|
+
finishUpload();
|
|
355
|
+
await addTask;
|
|
356
|
+
|
|
357
|
+
expect(composer.attachments).toHaveLength(0);
|
|
358
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("cancels an add reset before its first yield", async () => {
|
|
362
|
+
let startUpload!: () => void;
|
|
363
|
+
const uploadPending = new Promise<void>((resolve) => {
|
|
364
|
+
startUpload = resolve;
|
|
365
|
+
});
|
|
366
|
+
const composer = makeComposer(
|
|
367
|
+
makeAdapter({
|
|
368
|
+
async *add({ file }: { file: File }) {
|
|
369
|
+
await uploadPending;
|
|
370
|
+
yield makeUploadingAttachment(file, "att-1");
|
|
371
|
+
},
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
const onAdd = vi.fn();
|
|
375
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
376
|
+
|
|
377
|
+
const addTask = composer.addAttachment(
|
|
378
|
+
new File(["x"], "f.png", { type: "image/png" }),
|
|
379
|
+
);
|
|
380
|
+
await composer.reset();
|
|
381
|
+
startUpload();
|
|
382
|
+
await addTask;
|
|
383
|
+
|
|
384
|
+
expect(composer.attachments).toHaveLength(0);
|
|
385
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("cancels every concurrent add sharing an attachment id", async () => {
|
|
389
|
+
let finishUploads!: () => void;
|
|
390
|
+
const uploadsPending = new Promise<void>((resolve) => {
|
|
391
|
+
finishUploads = resolve;
|
|
392
|
+
});
|
|
393
|
+
const firstYieldsConsumed = vi.fn();
|
|
394
|
+
const composer = makeComposer(
|
|
395
|
+
makeAdapter({
|
|
396
|
+
async *add({ file }: { file: File }) {
|
|
397
|
+
const attachment = makeUploadingAttachment(file, "shared");
|
|
398
|
+
yield attachment;
|
|
399
|
+
firstYieldsConsumed();
|
|
400
|
+
await uploadsPending;
|
|
401
|
+
yield {
|
|
402
|
+
...attachment,
|
|
403
|
+
status: { type: "requires-action", reason: "composer-send" },
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
const onAdd = vi.fn();
|
|
409
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
410
|
+
|
|
411
|
+
const addTasks = [
|
|
412
|
+
composer.addAttachment(new File(["a"], "a.png", { type: "image/png" })),
|
|
413
|
+
composer.addAttachment(new File(["b"], "b.png", { type: "image/png" })),
|
|
414
|
+
];
|
|
415
|
+
await vi.waitFor(() => {
|
|
416
|
+
expect(firstYieldsConsumed).toHaveBeenCalledTimes(2);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await composer.removeAttachment("shared");
|
|
420
|
+
finishUploads();
|
|
421
|
+
await Promise.all(addTasks);
|
|
422
|
+
|
|
423
|
+
expect(composer.attachments).toHaveLength(0);
|
|
424
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
425
|
+
});
|
|
426
|
+
|
|
131
427
|
it("adds prepared attachments when File is unavailable", async () => {
|
|
132
428
|
const composer = makeComposer();
|
|
133
429
|
const fileConstructor = globalThis.File;
|
|
@@ -4,11 +4,13 @@ import type { ExternalStoreThreadRuntimeCore } from "../runtimes/external-store/
|
|
|
4
4
|
import type { ExternalStoreThreadListAdapter } from "../runtimes/external-store/external-store-adapter";
|
|
5
5
|
import { ThreadListRuntimeImpl } from "../runtime/api/thread-list-runtime";
|
|
6
6
|
|
|
7
|
-
const makeFactory = () =>
|
|
7
|
+
const makeFactory = (overrides: Record<string, unknown> = {}) =>
|
|
8
8
|
vi.fn(
|
|
9
9
|
() =>
|
|
10
10
|
({
|
|
11
11
|
subscribe: () => () => {},
|
|
12
|
+
capabilities: { cancel: false },
|
|
13
|
+
...overrides,
|
|
12
14
|
}) as unknown as ExternalStoreThreadRuntimeCore,
|
|
13
15
|
);
|
|
14
16
|
|
|
@@ -244,3 +246,39 @@ describe("ExternalStoreThreadListRuntimeCore - switchToThread", () => {
|
|
|
244
246
|
expect(onSwitchToThread).not.toHaveBeenCalled();
|
|
245
247
|
});
|
|
246
248
|
});
|
|
249
|
+
|
|
250
|
+
describe("ExternalStoreThreadListRuntimeCore.reloadMainThread", () => {
|
|
251
|
+
it("dispatches to the main thread's refetch capability", async () => {
|
|
252
|
+
const refetch = vi.fn(async () => {});
|
|
253
|
+
const core = new ExternalStoreThreadListRuntimeCore(
|
|
254
|
+
makeAdapter(),
|
|
255
|
+
makeFactory({ unstable_refetchThread: refetch }),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
await core.reloadMainThread();
|
|
259
|
+
|
|
260
|
+
expect(refetch).toHaveBeenCalledOnce();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("resolves quietly when the adapter declares no refetch capability", async () => {
|
|
264
|
+
const core = new ExternalStoreThreadListRuntimeCore(
|
|
265
|
+
makeAdapter(),
|
|
266
|
+
makeFactory(),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await expect(core.reloadMainThread()).resolves.toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("propagates a refetch failure", async () => {
|
|
273
|
+
const core = new ExternalStoreThreadListRuntimeCore(
|
|
274
|
+
makeAdapter(),
|
|
275
|
+
makeFactory({
|
|
276
|
+
unstable_refetchThread: async () => {
|
|
277
|
+
throw new Error("refetch failed");
|
|
278
|
+
},
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
await expect(core.reloadMainThread()).rejects.toThrow("refetch failed");
|
|
283
|
+
});
|
|
284
|
+
});
|
package/src/types/message.ts
CHANGED
|
@@ -62,9 +62,20 @@ export type FileMessagePart = {
|
|
|
62
62
|
readonly filename?: string;
|
|
63
63
|
readonly data: string;
|
|
64
64
|
readonly mimeType: string;
|
|
65
|
+
/** How `data` goes on the wire: a url or id reference; omitted = inferred (http(s) → url, else base64). "url" is honored by the LangChain-family, A2A, AG-UI, and Google ADK runtimes; "id" by the LangChain family only. */
|
|
66
|
+
readonly sourceType?: "url" | "id";
|
|
65
67
|
readonly parentId?: string;
|
|
66
68
|
};
|
|
67
69
|
|
|
70
|
+
/**
|
|
71
|
+
* @deprecated Use {@link FileMessagePart} with an `audio/*` mime type. `file`
|
|
72
|
+
* is the carrier for every non-image binary modality: it is a member of both
|
|
73
|
+
* the user and assistant unions, carries a filename, and can declare how its
|
|
74
|
+
* payload goes on the wire, none of which this shape can express. The payload
|
|
75
|
+
* form a `file` part needs is still adapter specific; see the Part Types
|
|
76
|
+
* section of the message primitive docs. Honored everywhere it is accepted; it
|
|
77
|
+
* will not gain fields.
|
|
78
|
+
*/
|
|
68
79
|
export type Unstable_AudioMessagePart = {
|
|
69
80
|
readonly type: "audio";
|
|
70
81
|
readonly audio: {
|
package/src/utils/data-url.ts
CHANGED
|
@@ -7,3 +7,27 @@ export function parseDataUrl(
|
|
|
7
7
|
if (!match) return null;
|
|
8
8
|
return { mimeType: match[1]!.toLowerCase(), data: match[2]! };
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether a payload is something `new URL()` accepts. Adapters that place a
|
|
13
|
+
* `FileMessagePart` payload into a url-typed wire field use this to decide
|
|
14
|
+
* whether it has to be wrapped in a data URL envelope first; base64 cannot
|
|
15
|
+
* contain a colon, so a real payload is never misread as a url.
|
|
16
|
+
*/
|
|
17
|
+
export function isParsableUrl(value: string): boolean {
|
|
18
|
+
try {
|
|
19
|
+
new URL(value);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The media type a data URL declares, whether or not its payload is base64.
|
|
28
|
+
* `parseDataUrl` only matches base64 payloads because it also returns the
|
|
29
|
+
* bytes; this reads the declaration alone.
|
|
30
|
+
*/
|
|
31
|
+
export function dataUrlMediaType(value: string): string | undefined {
|
|
32
|
+
return /^data:([^;,]+)(?:[;,])/i.exec(value)?.[1]?.toLowerCase();
|
|
33
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { detectImageMediaType } from "./image-media-type";
|
|
3
|
+
|
|
4
|
+
const toBase64 = (bytes: number[]) =>
|
|
5
|
+
Buffer.from(Uint8Array.from(bytes)).toString("base64");
|
|
6
|
+
|
|
7
|
+
describe("detectImageMediaType", () => {
|
|
8
|
+
it.each([
|
|
9
|
+
{
|
|
10
|
+
label: "gif",
|
|
11
|
+
bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
|
|
12
|
+
type: "image/gif",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
label: "png",
|
|
16
|
+
bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
17
|
+
type: "image/png",
|
|
18
|
+
},
|
|
19
|
+
{ label: "jpeg", bytes: [0xff, 0xd8, 0xff, 0xe0], type: "image/jpeg" },
|
|
20
|
+
{ label: "bmp", bytes: [0x42, 0x4d, 0x00, 0x00], type: "image/bmp" },
|
|
21
|
+
{
|
|
22
|
+
label: "tiff little endian",
|
|
23
|
+
bytes: [0x49, 0x49, 0x2a, 0x00],
|
|
24
|
+
type: "image/tiff",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: "tiff big endian",
|
|
28
|
+
bytes: [0x4d, 0x4d, 0x00, 0x2a],
|
|
29
|
+
type: "image/tiff",
|
|
30
|
+
},
|
|
31
|
+
])("detects $label", ({ bytes, type }) => {
|
|
32
|
+
expect(detectImageMediaType(toBase64(bytes))).toBe(type);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("detects webp across its variable size bytes", () => {
|
|
36
|
+
const bytes = [
|
|
37
|
+
0x52, 0x49, 0x46, 0x46, 0xde, 0xad, 0xbe, 0xef, 0x57, 0x45, 0x42, 0x50,
|
|
38
|
+
];
|
|
39
|
+
expect(detectImageMediaType(toBase64(bytes))).toBe("image/webp");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each([
|
|
43
|
+
{ label: "avif", eighth: [0x61, 0x76, 0x69, 0x66], type: "image/avif" },
|
|
44
|
+
{ label: "heic", eighth: [0x68, 0x65, 0x69, 0x63], type: "image/heic" },
|
|
45
|
+
])("detects $label whatever its ftyp box size", ({ eighth, type }) => {
|
|
46
|
+
for (const size of [0x20, 0x18, 0x1c]) {
|
|
47
|
+
const bytes = [0x00, 0x00, 0x00, size, 0x66, 0x74, 0x79, 0x70, ...eighth];
|
|
48
|
+
expect(detectImageMediaType(toBase64(bytes))).toBe(type);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("without Buffer, as in a browser", () => {
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.unstubAllGlobals();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("decodes through atob", () => {
|
|
58
|
+
// encoded before the stub, since the helper itself uses Buffer
|
|
59
|
+
const jpeg = toBase64([0xff, 0xd8, 0xff, 0xe0]);
|
|
60
|
+
const webp = toBase64([
|
|
61
|
+
0x52, 0x49, 0x46, 0x46, 0xde, 0xad, 0xbe, 0xef, 0x57, 0x45, 0x42, 0x50,
|
|
62
|
+
]);
|
|
63
|
+
vi.stubGlobal("Buffer", undefined);
|
|
64
|
+
|
|
65
|
+
expect(detectImageMediaType(jpeg)).toBe("image/jpeg");
|
|
66
|
+
expect(detectImageMediaType(webp)).toBe("image/webp");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns undefined when atob rejects the input", () => {
|
|
70
|
+
vi.stubGlobal("Buffer", undefined);
|
|
71
|
+
|
|
72
|
+
expect(detectImageMediaType("$$$$")).toBe(undefined);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns undefined for bytes matching no signature", () => {
|
|
77
|
+
expect(detectImageMediaType(toBase64([0x01, 0x02, 0x03, 0x04]))).toBe(
|
|
78
|
+
undefined,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not throw on input that is not base64", () => {
|
|
83
|
+
for (const value of [
|
|
84
|
+
"not base64 at all!!",
|
|
85
|
+
"",
|
|
86
|
+
"$$$$",
|
|
87
|
+
"https://x.example",
|
|
88
|
+
]) {
|
|
89
|
+
expect(() => detectImageMediaType(value)).not.toThrow();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns undefined for a payload shorter than any signature", () => {
|
|
94
|
+
expect(detectImageMediaType("/w==")).toBe(undefined);
|
|
95
|
+
});
|
|
96
|
+
});
|