@assistant-ui/react 0.15.4 → 0.15.5
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/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +383 -368
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/context/providers/MessageProvider.js +15 -34
- package/dist/context/providers/MessageProvider.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts +6 -1
- package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts.map +1 -1
- package/dist/legacy-runtime/AssistantRuntimeProvider.js +10 -8
- package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +8 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +5 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +44 -3
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
- package/dist/primitives/attachment/AttachmentThumb.d.ts.map +1 -1
- package/dist/primitives/attachment/AttachmentThumb.js +17 -14
- package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +1 -0
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemText.d.ts.map +1 -1
- package/dist/primitives/queueItem/QueueItemText.js +10 -4
- package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -4
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/utils/createActionButton.js +1 -1
- package/dist/utils/createActionButton.js.map +1 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/package.json +6 -6
- package/src/client/ExternalThread.ts +28 -16
- package/src/context/providers/MessageProvider.tsx +8 -6
- package/src/index.ts +5 -1
- package/src/legacy-runtime/AssistantRuntimeProvider.tsx +13 -3
- package/src/legacy-runtime/runtime-cores/assistant-transport/runManager.ts +14 -1
- package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +38 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +5 -1
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +7 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +290 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +62 -1
- package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +1 -1
- package/src/primitives/attachment/AttachmentThumb.test.tsx +70 -0
- package/src/primitives/attachment/AttachmentThumb.tsx +8 -4
- package/src/primitives/composer/ComposerInput.test.tsx +53 -2
- package/src/primitives/composer/ComposerInput.tsx +3 -0
- package/src/primitives/queueItem/QueueItemText.tsx +8 -2
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +74 -0
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +3 -3
- package/src/tests/external-thread-parity.test.tsx +9 -4
- package/src/tests/local-runtime-queue.test.tsx +199 -6
- package/src/utils/createActionButton.test.tsx +18 -0
- package/src/utils/createActionButton.tsx +1 -1
package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx
CHANGED
|
@@ -137,6 +137,7 @@ describe("useAssistantTransportRuntime", () => {
|
|
|
137
137
|
expect(
|
|
138
138
|
fetchMock.requests[0]!.body["commands"].map((c: any) => c.type),
|
|
139
139
|
).toEqual(["add-message", "add-message"]);
|
|
140
|
+
expect(fetchMock.requests[0]!.body["state"]).toEqual({});
|
|
140
141
|
|
|
141
142
|
act(() => fetchMock.servers[0]!.close());
|
|
142
143
|
|
|
@@ -201,6 +202,7 @@ describe("useAssistantTransportRuntime", () => {
|
|
|
201
202
|
await waitFor(() => expect(fetchMock.requests).toHaveLength(2));
|
|
202
203
|
expect(fetchMock.requests[1]!.url).toBe("https://example.com/resume");
|
|
203
204
|
expect(fetchMock.requests[1]!.body["commands"]).toEqual([]);
|
|
205
|
+
expect(fetchMock.requests[1]!.body).toHaveProperty("state");
|
|
204
206
|
|
|
205
207
|
// "b" coalesced into the resume run and must not starve in the queue.
|
|
206
208
|
act(() => fetchMock.servers[1]!.close());
|
|
@@ -213,4 +215,292 @@ describe("useAssistantTransportRuntime", () => {
|
|
|
213
215
|
act(() => fetchMock.servers[2]!.close());
|
|
214
216
|
await waitFor(() => expect(aui().thread.getState().isRunning).toBe(false));
|
|
215
217
|
});
|
|
218
|
+
|
|
219
|
+
it("applies resumed operations to the retained initial state", async () => {
|
|
220
|
+
const requests: RecordedRequest[] = [];
|
|
221
|
+
vi.stubGlobal(
|
|
222
|
+
"fetch",
|
|
223
|
+
async (url: RequestInfo | URL, init: RequestInit = {}) => {
|
|
224
|
+
requests.push({
|
|
225
|
+
url: String(url),
|
|
226
|
+
init,
|
|
227
|
+
body: JSON.parse(init.body as string),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (String(url) === "https://example.com/resume-state") {
|
|
231
|
+
return Response.json({
|
|
232
|
+
runId: "run-1",
|
|
233
|
+
state: { message: "Hello" },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return new Response(
|
|
238
|
+
'aui-state:[{"type":"append-text","path":["message"],"value":" world"}]\n',
|
|
239
|
+
{ status: 200 },
|
|
240
|
+
);
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
const { aui } = mountRuntime({
|
|
244
|
+
resumeApi: "https://example.com/resume",
|
|
245
|
+
resumeStateApi: "https://example.com/resume-state",
|
|
246
|
+
});
|
|
247
|
+
await waitFor(() =>
|
|
248
|
+
expect(
|
|
249
|
+
(aui().thread.getState().extras as { sendCommand?: unknown })
|
|
250
|
+
?.sendCommand,
|
|
251
|
+
).toBeTypeOf("function"),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
act(() => {
|
|
255
|
+
aui().thread.importExternalState({ message: "Wrong" });
|
|
256
|
+
});
|
|
257
|
+
await act(async () => {
|
|
258
|
+
await aui().thread.resumeRun({ parentId: null });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await waitFor(() =>
|
|
262
|
+
expect(
|
|
263
|
+
(aui().thread.getState().extras as { state: unknown }).state,
|
|
264
|
+
).toEqual({ message: "Hello world" }),
|
|
265
|
+
);
|
|
266
|
+
expect(requests.map((request) => request.url)).toEqual([
|
|
267
|
+
"https://example.com/resume-state",
|
|
268
|
+
"https://example.com/resume",
|
|
269
|
+
]);
|
|
270
|
+
expect(requests[1]!.body).toMatchObject({ runId: "run-1" });
|
|
271
|
+
expect(requests[1]!.body).not.toHaveProperty("state");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("rejects malformed resume state responses before replay", async () => {
|
|
275
|
+
const fetchMock = vi.fn(async () => Response.json({ state: {} }));
|
|
276
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
277
|
+
const onError = vi.fn();
|
|
278
|
+
const { aui } = mountRuntime({
|
|
279
|
+
resumeApi: "https://example.com/resume",
|
|
280
|
+
resumeStateApi: "https://example.com/resume-state",
|
|
281
|
+
onError,
|
|
282
|
+
});
|
|
283
|
+
await waitFor(() =>
|
|
284
|
+
expect(
|
|
285
|
+
(aui().thread.getState().extras as { sendCommand?: unknown })
|
|
286
|
+
?.sendCommand,
|
|
287
|
+
).toBeTypeOf("function"),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
await act(async () => {
|
|
291
|
+
await aui().thread.resumeRun({ parentId: null });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await waitFor(() =>
|
|
295
|
+
expect(onError).toHaveBeenCalledWith(
|
|
296
|
+
expect.objectContaining({
|
|
297
|
+
message: "Resume state response must contain state and runId",
|
|
298
|
+
}),
|
|
299
|
+
expect.anything(),
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("commits a retained null state locally and omits state from the resume request", async () => {
|
|
306
|
+
const requests: RecordedRequest[] = [];
|
|
307
|
+
vi.stubGlobal(
|
|
308
|
+
"fetch",
|
|
309
|
+
async (url: RequestInfo | URL, init: RequestInit = {}) => {
|
|
310
|
+
requests.push({
|
|
311
|
+
url: String(url),
|
|
312
|
+
init,
|
|
313
|
+
body: JSON.parse(init.body as string),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
if (String(url) === "https://example.com/resume-state") {
|
|
317
|
+
return Response.json({ runId: "run-1", state: null });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return new Response("", { status: 200 });
|
|
321
|
+
},
|
|
322
|
+
);
|
|
323
|
+
const { aui } = mountRuntime({
|
|
324
|
+
resumeApi: "https://example.com/resume",
|
|
325
|
+
resumeStateApi: "https://example.com/resume-state",
|
|
326
|
+
});
|
|
327
|
+
await waitFor(() =>
|
|
328
|
+
expect(
|
|
329
|
+
(aui().thread.getState().extras as { sendCommand?: unknown })
|
|
330
|
+
?.sendCommand,
|
|
331
|
+
).toBeTypeOf("function"),
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
act(() => {
|
|
335
|
+
aui().thread.importExternalState({ message: "Wrong" });
|
|
336
|
+
});
|
|
337
|
+
await act(async () => {
|
|
338
|
+
await aui().thread.resumeRun({ parentId: null });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(requests[1]!.body).toMatchObject({ runId: "run-1" });
|
|
342
|
+
expect(requests[1]!.body).not.toHaveProperty("state");
|
|
343
|
+
await waitFor(() =>
|
|
344
|
+
expect(
|
|
345
|
+
(aui().thread.getState().extras as { state: unknown }).state,
|
|
346
|
+
).toBeNull(),
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("skips the resume without error when the state endpoint reports no active run", async () => {
|
|
351
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
|
352
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
353
|
+
const onError = vi.fn();
|
|
354
|
+
const { aui } = mountRuntime({
|
|
355
|
+
resumeApi: "https://example.com/resume",
|
|
356
|
+
resumeStateApi: "https://example.com/resume-state",
|
|
357
|
+
onError,
|
|
358
|
+
});
|
|
359
|
+
await waitFor(() =>
|
|
360
|
+
expect(
|
|
361
|
+
(aui().thread.getState().extras as { sendCommand?: unknown })
|
|
362
|
+
?.sendCommand,
|
|
363
|
+
).toBeTypeOf("function"),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
act(() => {
|
|
367
|
+
aui().thread.importExternalState({ message: "Kept" });
|
|
368
|
+
});
|
|
369
|
+
await act(async () => {
|
|
370
|
+
await aui().thread.resumeRun({ parentId: null });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await waitFor(() => expect(aui().thread.getState().isRunning).toBe(false));
|
|
374
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
375
|
+
expect(onError).not.toHaveBeenCalled();
|
|
376
|
+
expect(
|
|
377
|
+
(aui().thread.getState().extras as { state: unknown }).state,
|
|
378
|
+
).toEqual({ message: "Kept" });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("keeps the retained runId over body overrides in the resume request", async () => {
|
|
382
|
+
const requests: RecordedRequest[] = [];
|
|
383
|
+
vi.stubGlobal(
|
|
384
|
+
"fetch",
|
|
385
|
+
async (url: RequestInfo | URL, init: RequestInit = {}) => {
|
|
386
|
+
requests.push({
|
|
387
|
+
url: String(url),
|
|
388
|
+
init,
|
|
389
|
+
body: JSON.parse(init.body as string),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
if (String(url) === "https://example.com/resume-state") {
|
|
393
|
+
return Response.json({
|
|
394
|
+
runId: "run-1",
|
|
395
|
+
state: { message: "Hello" },
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return new Response("", { status: 200 });
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
const { aui } = mountRuntime({
|
|
403
|
+
resumeApi: "https://example.com/resume",
|
|
404
|
+
resumeStateApi: "https://example.com/resume-state",
|
|
405
|
+
body: { state: { message: "Injected" }, runId: "bogus" },
|
|
406
|
+
});
|
|
407
|
+
await waitFor(() =>
|
|
408
|
+
expect(
|
|
409
|
+
(aui().thread.getState().extras as { sendCommand?: unknown })
|
|
410
|
+
?.sendCommand,
|
|
411
|
+
).toBeTypeOf("function"),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
await act(async () => {
|
|
415
|
+
await aui().thread.resumeRun({ parentId: null });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(requests[1]!.body["runId"]).toBe("run-1");
|
|
419
|
+
expect(requests[1]!.body).not.toHaveProperty("state");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("re-attaches runId and strips substituted state when prepareSendCommandsRequest rebuilds the body", async () => {
|
|
423
|
+
const requests: RecordedRequest[] = [];
|
|
424
|
+
vi.stubGlobal(
|
|
425
|
+
"fetch",
|
|
426
|
+
async (url: RequestInfo | URL, init: RequestInit = {}) => {
|
|
427
|
+
requests.push({
|
|
428
|
+
url: String(url),
|
|
429
|
+
init,
|
|
430
|
+
body: JSON.parse(init.body as string),
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
if (String(url) === "https://example.com/resume-state") {
|
|
434
|
+
return Response.json({
|
|
435
|
+
runId: "run-1",
|
|
436
|
+
state: { message: "Hello" },
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return new Response("", { status: 200 });
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
const { aui } = mountRuntime({
|
|
444
|
+
resumeApi: "https://example.com/resume",
|
|
445
|
+
resumeStateApi: "https://example.com/resume-state",
|
|
446
|
+
prepareSendCommandsRequest: (body) => ({
|
|
447
|
+
commands: body.commands,
|
|
448
|
+
state: { message: "Substituted" },
|
|
449
|
+
rebuilt: true,
|
|
450
|
+
}),
|
|
451
|
+
});
|
|
452
|
+
await waitFor(() =>
|
|
453
|
+
expect(
|
|
454
|
+
(aui().thread.getState().extras as { sendCommand?: unknown })
|
|
455
|
+
?.sendCommand,
|
|
456
|
+
).toBeTypeOf("function"),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
await act(async () => {
|
|
460
|
+
await aui().thread.resumeRun({ parentId: null });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(requests[1]!.body).toMatchObject({ runId: "run-1", rebuilt: true });
|
|
464
|
+
expect(requests[1]!.body).not.toHaveProperty("state");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("keeps local state when the matching resume stream is rejected", async () => {
|
|
468
|
+
const fetchMock = vi
|
|
469
|
+
.fn()
|
|
470
|
+
.mockResolvedValueOnce(
|
|
471
|
+
Response.json({ runId: "run-1", state: { message: "Hello" } }),
|
|
472
|
+
)
|
|
473
|
+
.mockResolvedValueOnce(new Response("run mismatch", { status: 409 }));
|
|
474
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
475
|
+
const onError = vi.fn();
|
|
476
|
+
const { aui } = mountRuntime({
|
|
477
|
+
resumeApi: "https://example.com/resume",
|
|
478
|
+
resumeStateApi: "https://example.com/resume-state",
|
|
479
|
+
onError,
|
|
480
|
+
});
|
|
481
|
+
await waitFor(() =>
|
|
482
|
+
expect(
|
|
483
|
+
(aui().thread.getState().extras as { sendCommand?: unknown })
|
|
484
|
+
?.sendCommand,
|
|
485
|
+
).toBeTypeOf("function"),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
act(() => {
|
|
489
|
+
aui().thread.importExternalState({ message: "Wrong" });
|
|
490
|
+
});
|
|
491
|
+
await act(async () => {
|
|
492
|
+
await aui().thread.resumeRun({ parentId: null });
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
await waitFor(() =>
|
|
496
|
+
expect(onError).toHaveBeenCalledWith(
|
|
497
|
+
expect.objectContaining({ message: "Status 409: run mismatch" }),
|
|
498
|
+
expect.anything(),
|
|
499
|
+
),
|
|
500
|
+
);
|
|
501
|
+
expect(
|
|
502
|
+
(aui().thread.getState().extras as { state: unknown }).state,
|
|
503
|
+
).toEqual({ message: "Wrong" });
|
|
504
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
505
|
+
});
|
|
216
506
|
});
|
package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts
CHANGED
|
@@ -72,6 +72,36 @@ const convertAppendMessageToCommand = (
|
|
|
72
72
|
};
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
const readResumeState = async <T>(
|
|
76
|
+
response: Response,
|
|
77
|
+
): Promise<{ runId: string; state: T } | null> => {
|
|
78
|
+
if (response.status === 204) return null;
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Resume state request failed with status ${response.status}: ${await response.text()}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let value: unknown;
|
|
86
|
+
try {
|
|
87
|
+
value = await response.json();
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error("Resume state response was not valid JSON");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
typeof value !== "object" ||
|
|
94
|
+
value === null ||
|
|
95
|
+
!("state" in value) ||
|
|
96
|
+
!("runId" in value) ||
|
|
97
|
+
typeof value.runId !== "string"
|
|
98
|
+
) {
|
|
99
|
+
throw new Error("Resume state response must contain state and runId");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { runId: value.runId, state: value.state as T };
|
|
103
|
+
};
|
|
104
|
+
|
|
75
105
|
const symbolAssistantTransportExtras = Symbol("assistant-transport-extras");
|
|
76
106
|
type AssistantTransportExtras = {
|
|
77
107
|
[symbolAssistantTransportExtras]: true;
|
|
@@ -160,6 +190,24 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
160
190
|
if (!isResume) parentIdRef.current = undefined;
|
|
161
191
|
|
|
162
192
|
const headers = await createRequestHeaders(options.headers);
|
|
193
|
+
let resumeState: { runId: string; state: T } | undefined;
|
|
194
|
+
if (isResume && options.resumeStateApi) {
|
|
195
|
+
const resumeStateResponse = await fetch(options.resumeStateApi, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers,
|
|
198
|
+
body: JSON.stringify({ threadId }),
|
|
199
|
+
signal,
|
|
200
|
+
});
|
|
201
|
+
const retained = await readResumeState<T>(resumeStateResponse);
|
|
202
|
+
if (retained === null) {
|
|
203
|
+
if (commandQueue.state.queued.length > 0) {
|
|
204
|
+
runManager.schedule();
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
resumeState = retained;
|
|
209
|
+
}
|
|
210
|
+
|
|
163
211
|
const bodyValue =
|
|
164
212
|
typeof options.body === "function"
|
|
165
213
|
? await options.body()
|
|
@@ -168,7 +216,7 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
168
216
|
|
|
169
217
|
let requestBody: Record<string, unknown> = {
|
|
170
218
|
commands,
|
|
171
|
-
state: agentStateRef.current,
|
|
219
|
+
...(resumeState === undefined && { state: agentStateRef.current }),
|
|
172
220
|
system: context.system,
|
|
173
221
|
tools: context.tools ? toToolsJSONSchema(context.tools) : undefined,
|
|
174
222
|
threadId,
|
|
@@ -190,6 +238,14 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
190
238
|
);
|
|
191
239
|
}
|
|
192
240
|
|
|
241
|
+
if (resumeState !== undefined) {
|
|
242
|
+
// The server replays a resume from the snapshot it retained for this
|
|
243
|
+
// runId. Body overrides and prepare hooks can neither substitute a
|
|
244
|
+
// state nor drop the ID the server validates against.
|
|
245
|
+
requestBody = { ...requestBody, runId: resumeState.runId };
|
|
246
|
+
delete requestBody["state"];
|
|
247
|
+
}
|
|
248
|
+
|
|
193
249
|
const response = await fetch(
|
|
194
250
|
isResume ? options.resumeApi! : options.api,
|
|
195
251
|
{
|
|
@@ -210,6 +266,11 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
210
266
|
throw new Error("Response body is null");
|
|
211
267
|
}
|
|
212
268
|
|
|
269
|
+
if (resumeState !== undefined) {
|
|
270
|
+
agentStateRef.current = resumeState.state;
|
|
271
|
+
rerender((prev) => prev + 1);
|
|
272
|
+
}
|
|
273
|
+
|
|
213
274
|
const body = await createReplayBoundaryStream(response, {
|
|
214
275
|
setReplaying: setIsReplaying,
|
|
215
276
|
waitForRender: waitForReplayRender,
|
|
@@ -37,7 +37,7 @@ const useActionBarExportMarkdown = ({
|
|
|
37
37
|
a.href = url;
|
|
38
38
|
a.download = filename ?? `message-${Date.now()}.md`;
|
|
39
39
|
a.click();
|
|
40
|
-
URL.revokeObjectURL(url);
|
|
40
|
+
setTimeout(() => URL.revokeObjectURL(url), 40_000);
|
|
41
41
|
}, [aui, filename, onExport]);
|
|
42
42
|
|
|
43
43
|
if (!hasExportableContent) return null;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import type * as AssistantStore from "@assistant-ui/store";
|
|
4
|
+
import { AttachmentPrimitiveThumb } from "./AttachmentThumb";
|
|
5
|
+
|
|
6
|
+
const mockUseAuiState = vi.fn();
|
|
7
|
+
type UseAuiStateSelector = Parameters<
|
|
8
|
+
(typeof AssistantStore)["useAuiState"]
|
|
9
|
+
>[0];
|
|
10
|
+
|
|
11
|
+
vi.mock("@assistant-ui/store", async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal<typeof AssistantStore>();
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
useAuiState: (selector: UseAuiStateSelector) => mockUseAuiState(selector),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const renderThumb = (
|
|
20
|
+
name: string,
|
|
21
|
+
type = "file",
|
|
22
|
+
props?: AttachmentPrimitiveThumb.Props,
|
|
23
|
+
) => {
|
|
24
|
+
mockUseAuiState.mockImplementation((selector: UseAuiStateSelector) =>
|
|
25
|
+
selector({ attachment: { name, type } } as never),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return renderToStaticMarkup(<AttachmentPrimitiveThumb {...props} />);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("AttachmentPrimitiveThumb", () => {
|
|
32
|
+
it("renders the dotted extension for a single-extension name", () => {
|
|
33
|
+
expect(renderThumb("photo.png")).toBe("<div>.png</div>");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("takes only the last segment for a multi-extension name", () => {
|
|
37
|
+
expect(renderThumb("archive.tar.gz")).toBe("<div>.gz</div>");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("falls back to the attachment type when the name has no extension", () => {
|
|
41
|
+
expect(renderThumb("noext", "document")).toBe("<div>document</div>");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("falls back to the attachment type for an empty name", () => {
|
|
45
|
+
expect(renderThumb("", "image")).toBe("<div>image</div>");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("treats a leading-dot name as extensionless", () => {
|
|
49
|
+
expect(renderThumb(".gitignore")).toBe("<div>file</div>");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("falls back to the attachment type for a trailing-dot name", () => {
|
|
53
|
+
expect(renderThumb("report.", "image")).toBe("<div>image</div>");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("renders custom children instead of the label", () => {
|
|
57
|
+
expect(renderThumb("report.pdf", "file", { children: <em>PDF</em> })).toBe(
|
|
58
|
+
"<div><em>PDF</em></div>",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders the child element when asChild is set", () => {
|
|
63
|
+
expect(
|
|
64
|
+
renderThumb("report.pdf", "file", {
|
|
65
|
+
asChild: true,
|
|
66
|
+
children: <span>custom</span>,
|
|
67
|
+
}),
|
|
68
|
+
).toBe("<span>custom</span>");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -19,13 +19,17 @@ export const AttachmentPrimitiveThumb = forwardRef<
|
|
|
19
19
|
AttachmentPrimitiveThumb.Element,
|
|
20
20
|
AttachmentPrimitiveThumb.Props
|
|
21
21
|
>((props, ref) => {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
22
|
+
const label = useAuiState((s) => {
|
|
23
|
+
const name = s.attachment.name;
|
|
24
|
+
const dot = name.lastIndexOf(".");
|
|
25
|
+
if (dot > 0 && dot < name.length - 1) {
|
|
26
|
+
return `.${name.slice(dot + 1)}`;
|
|
27
|
+
}
|
|
28
|
+
return s.attachment.type;
|
|
25
29
|
});
|
|
26
30
|
return (
|
|
27
31
|
<Primitive.div {...props} ref={ref}>
|
|
28
|
-
.
|
|
32
|
+
{props.children ?? label}
|
|
29
33
|
</Primitive.div>
|
|
30
34
|
);
|
|
31
35
|
});
|
|
@@ -10,6 +10,7 @@ const setText = vi.fn<(text: string) => void>();
|
|
|
10
10
|
const setCursorPosition = vi.fn<(pos: number) => void>();
|
|
11
11
|
const sendSpy = vi.fn<(options?: { steer?: boolean }) => void>();
|
|
12
12
|
const addAttachment = vi.fn<(file: File) => Promise<void>>();
|
|
13
|
+
const cancelSpy = vi.fn<() => void>();
|
|
13
14
|
|
|
14
15
|
const composerState = {
|
|
15
16
|
isEditing: true,
|
|
@@ -41,7 +42,7 @@ vi.mock("@assistant-ui/store", () => {
|
|
|
41
42
|
composer: {
|
|
42
43
|
setText: (text: string) => setText(text),
|
|
43
44
|
getState: () => composerState,
|
|
44
|
-
cancel: () =>
|
|
45
|
+
cancel: () => cancelSpy(),
|
|
45
46
|
send: (options?: { steer?: boolean }) => sendSpy(options),
|
|
46
47
|
addAttachment: (file: File) => addAttachment(file),
|
|
47
48
|
},
|
|
@@ -78,8 +79,12 @@ vi.mock("./trigger/TriggerPopoverRootContext", () => ({
|
|
|
78
79
|
useTriggerPopoverActiveAriaOptional: () => activeAria,
|
|
79
80
|
}));
|
|
80
81
|
|
|
82
|
+
let escapeKeydownHandler: ((event: KeyboardEvent) => void) | null = null;
|
|
83
|
+
|
|
81
84
|
vi.mock("@radix-ui/react-use-escape-keydown", () => ({
|
|
82
|
-
useEscapeKeydown: () => {
|
|
85
|
+
useEscapeKeydown: (handler: (event: KeyboardEvent) => void) => {
|
|
86
|
+
escapeKeydownHandler = handler;
|
|
87
|
+
},
|
|
83
88
|
}));
|
|
84
89
|
|
|
85
90
|
vi.mock("../../utils/hooks/useOnScrollToBottom", () => ({
|
|
@@ -181,16 +186,19 @@ describe("ComposerPrimitiveInput", () => {
|
|
|
181
186
|
setCursorPosition.mockReset();
|
|
182
187
|
sendSpy.mockReset();
|
|
183
188
|
addAttachment.mockReset();
|
|
189
|
+
cancelSpy.mockReset();
|
|
184
190
|
composerState.isEditing = true;
|
|
185
191
|
composerState.text = "";
|
|
186
192
|
composerState.isEmpty = true;
|
|
187
193
|
composerState.canSend = true;
|
|
188
194
|
composerState.dictation = undefined;
|
|
195
|
+
composerState.canCancel = false;
|
|
189
196
|
threadState.isDisabled = false;
|
|
190
197
|
threadState.isRunning = false;
|
|
191
198
|
threadState.capabilities = { queue: false, attachments: false };
|
|
192
199
|
pluginRegistry = null;
|
|
193
200
|
activeAria = null;
|
|
201
|
+
escapeKeydownHandler = null;
|
|
194
202
|
setMatchMedia(false);
|
|
195
203
|
|
|
196
204
|
requestSubmitSpy = vi.fn<(submitter?: HTMLElement | null) => void>();
|
|
@@ -514,6 +522,49 @@ describe("ComposerPrimitiveInput", () => {
|
|
|
514
522
|
});
|
|
515
523
|
});
|
|
516
524
|
|
|
525
|
+
describe("escape behavior", () => {
|
|
526
|
+
const fireEscape = (
|
|
527
|
+
textarea: HTMLTextAreaElement,
|
|
528
|
+
opts: { isComposing?: boolean } = {},
|
|
529
|
+
): KeyboardEvent => {
|
|
530
|
+
const event = new KeyboardEvent("keydown", {
|
|
531
|
+
bubbles: true,
|
|
532
|
+
cancelable: true,
|
|
533
|
+
key: "Escape",
|
|
534
|
+
isComposing: opts.isComposing ?? false,
|
|
535
|
+
});
|
|
536
|
+
textarea.dispatchEvent(event);
|
|
537
|
+
escapeKeydownHandler?.(event);
|
|
538
|
+
return event;
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
it("cancels the composer on Escape when cancellable", async () => {
|
|
542
|
+
composerState.canCancel = true;
|
|
543
|
+
const textarea = await mount();
|
|
544
|
+
|
|
545
|
+
let event!: KeyboardEvent;
|
|
546
|
+
await act(async () => {
|
|
547
|
+
event = fireEscape(textarea);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(cancelSpy).toHaveBeenCalledTimes(1);
|
|
551
|
+
expect(event.defaultPrevented).toBe(true);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("ignores Escape while an IME composition is active", async () => {
|
|
555
|
+
composerState.canCancel = true;
|
|
556
|
+
const textarea = await mount();
|
|
557
|
+
|
|
558
|
+
let event!: KeyboardEvent;
|
|
559
|
+
await act(async () => {
|
|
560
|
+
event = fireEscape(textarea, { isComposing: true });
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
expect(cancelSpy).not.toHaveBeenCalled();
|
|
564
|
+
expect(event.defaultPrevented).toBe(false);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
517
568
|
describe("paste attachments", () => {
|
|
518
569
|
it("attempts every pasted file and prevents default when attachments are supported", async () => {
|
|
519
570
|
threadState.capabilities = { queue: false, attachments: true };
|
|
@@ -199,6 +199,9 @@ export const ComposerPrimitiveInput = forwardRef<
|
|
|
199
199
|
// Only handle ESC if it originated from within this input
|
|
200
200
|
if (!textareaRef.current?.contains(e.target as Node)) return;
|
|
201
201
|
|
|
202
|
+
// ignore IME composition events
|
|
203
|
+
if (e.isComposing) return;
|
|
204
|
+
|
|
202
205
|
// Let registered plugins (mention, slash command, etc.) handle Escape first
|
|
203
206
|
if (pluginRegistry) {
|
|
204
207
|
for (const plugin of pluginRegistry.getPlugins()) {
|
|
@@ -25,11 +25,17 @@ export const QueueItemPrimitiveText = forwardRef<
|
|
|
25
25
|
QueueItemPrimitiveText.Element,
|
|
26
26
|
QueueItemPrimitiveText.Props
|
|
27
27
|
>((props, ref) => {
|
|
28
|
-
const
|
|
28
|
+
const text = useAuiState((s) =>
|
|
29
|
+
// hosts on the pre-parts adapter shape may omit the field at runtime
|
|
30
|
+
(s.queueItem.parts ?? [])
|
|
31
|
+
.filter((part) => part.type === "text")
|
|
32
|
+
.map((part) => part.text)
|
|
33
|
+
.join("\n\n"),
|
|
34
|
+
);
|
|
29
35
|
|
|
30
36
|
return (
|
|
31
37
|
<Primitive.span {...props} ref={ref}>
|
|
32
|
-
{props.children ??
|
|
38
|
+
{props.children ?? text}
|
|
33
39
|
</Primitive.span>
|
|
34
40
|
);
|
|
35
41
|
});
|