@assistant-ui/react 0.15.4 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/client/ExternalThread.d.ts.map +1 -1
  2. package/dist/client/ExternalThread.js +384 -368
  3. package/dist/client/ExternalThread.js.map +1 -1
  4. package/dist/context/providers/MessageProvider.js +15 -34
  5. package/dist/context/providers/MessageProvider.js.map +1 -1
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +2 -2
  8. package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts +6 -1
  9. package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts.map +1 -1
  10. package/dist/legacy-runtime/AssistantRuntimeProvider.js +10 -8
  11. package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
  12. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts.map +1 -1
  13. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +8 -1
  14. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js.map +1 -1
  15. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +5 -1
  16. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +1 -1
  17. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  18. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +44 -3
  19. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  20. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -1
  21. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  22. package/dist/primitives/attachment/AttachmentThumb.d.ts.map +1 -1
  23. package/dist/primitives/attachment/AttachmentThumb.js +17 -14
  24. package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
  25. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  26. package/dist/primitives/composer/ComposerAttachmentDropzone.js +64 -50
  27. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  28. package/dist/primitives/composer/ComposerInput.js +1 -0
  29. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  30. package/dist/primitives/queueItem/QueueItemText.d.ts.map +1 -1
  31. package/dist/primitives/queueItem/QueueItemText.js +10 -4
  32. package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
  33. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  34. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -4
  35. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  36. package/dist/utils/createActionButton.js +1 -1
  37. package/dist/utils/createActionButton.js.map +1 -1
  38. package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
  39. package/package.json +16 -14
  40. package/src/client/ExternalThread.ts +30 -17
  41. package/src/context/providers/MessageProvider.tsx +8 -6
  42. package/src/index.ts +5 -1
  43. package/src/legacy-runtime/AssistantRuntimeProvider.tsx +13 -3
  44. package/src/legacy-runtime/runtime-cores/assistant-transport/runManager.ts +14 -1
  45. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +38 -0
  46. package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +5 -1
  47. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +7 -0
  48. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +290 -0
  49. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +62 -1
  50. package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +1 -1
  51. package/src/primitives/attachment/AttachmentThumb.test.tsx +70 -0
  52. package/src/primitives/attachment/AttachmentThumb.tsx +8 -4
  53. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +103 -3
  54. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +18 -3
  55. package/src/primitives/composer/ComposerInput.test.tsx +53 -2
  56. package/src/primitives/composer/ComposerInput.tsx +3 -0
  57. package/src/primitives/queueItem/QueueItemText.tsx +8 -2
  58. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +74 -0
  59. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +3 -3
  60. package/src/tests/external-thread-parity.test.tsx +52 -4
  61. package/src/tests/local-runtime-queue.test.tsx +199 -6
  62. package/src/utils/createActionButton.test.tsx +18 -0
  63. package/src/utils/createActionButton.tsx +1 -1
  64. package/LICENSE +0 -21
@@ -1,7 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { type FC, memo, type PropsWithChildren, useEffect } from "react";
4
- import { type AssistantClient, useAui } from "@assistant-ui/store";
4
+ import {
5
+ type AssistantClient,
6
+ type AuiConfig,
7
+ useAui,
8
+ } from "@assistant-ui/store";
5
9
  import type { AssistantRuntime } from "./runtime/AssistantRuntime";
6
10
  import { AssistantProviderBase } from "@assistant-ui/core/react";
7
11
  import { ThreadPrimitiveViewportProvider } from "../context/providers/ThreadViewportProvider";
@@ -24,6 +28,12 @@ export namespace AssistantRuntimeProvider {
24
28
  * @defaultValue undefined
25
29
  */
26
30
  aui?: AssistantClient;
31
+
32
+ /**
33
+ * Optional extra scopes provided alongside the runtime's `threads`
34
+ * scope; build with `AuiConfig`.
35
+ */
36
+ config?: AuiConfig;
27
37
  }>;
28
38
  }
29
39
 
@@ -39,9 +49,9 @@ const DevToolsRegistration: FC = () => {
39
49
 
40
50
  export const AssistantRuntimeProviderImpl: FC<
41
51
  AssistantRuntimeProvider.Props
42
- > = ({ children, aui, runtime }) => {
52
+ > = ({ children, aui, config, runtime }) => {
43
53
  return (
44
- <AssistantProviderBase runtime={runtime} aui={aui ?? null}>
54
+ <AssistantProviderBase runtime={runtime} aui={aui ?? null} config={config}>
45
55
  <DevToolsRegistration />
46
56
  {/* TODO temporarily allow accessing viewport state from outside the viewport */}
47
57
  {/* TODO figure out if this behavior should be deprecated, since it is quite hacky */}
@@ -9,6 +9,13 @@ export type RunManager = Readonly<{
9
9
 
10
10
  const disposeReason = Symbol("assistant-transport-dispose");
11
11
 
12
+ const reportFinishError = (error: unknown) => {
13
+ console.error(
14
+ "[assistant-ui] Assistant transport onFinish callback threw an error",
15
+ error,
16
+ );
17
+ };
18
+
12
19
  export function useRunManager(config: {
13
20
  onRun: (signal: AbortSignal) => Promise<void>;
14
21
  onFinish?: (() => void) | undefined;
@@ -52,7 +59,13 @@ export function useRunManager(config: {
52
59
  }
53
60
  } finally {
54
61
  if (!disposeAborted() && !stateRef.current.disposed) {
55
- onFinishRef.current?.();
62
+ try {
63
+ void Promise.resolve(onFinishRef.current?.()).catch(
64
+ reportFinishError,
65
+ );
66
+ } catch (error) {
67
+ reportFinishError(error);
68
+ }
56
69
  }
57
70
  if (!stateRef.current.disposed && stateRef.current.pending) {
58
71
  startRun();
@@ -146,6 +146,44 @@ describe("assistant transport scheduling contracts", () => {
146
146
  expect(seen[0]).toEqual([createMessageCommand("m1")]);
147
147
  });
148
148
 
149
+ it("settles the run when onFinish throws", async () => {
150
+ const error = new Error("telemetry failed");
151
+ const onFinish = vi.fn<() => void>().mockImplementationOnce(() => {
152
+ throw error;
153
+ });
154
+ const consoleError = vi
155
+ .spyOn(console, "error")
156
+ .mockImplementation(() => {});
157
+
158
+ try {
159
+ const { result } = renderHook(() =>
160
+ useTransportSchedulingHarness({ onFinish }),
161
+ );
162
+
163
+ act(() => {
164
+ result.current.commandQueue.enqueue(createMessageCommand("m1"));
165
+ });
166
+
167
+ await waitFor(() => {
168
+ expect(result.current.runManager.isRunning).toBe(false);
169
+ });
170
+
171
+ act(() => {
172
+ result.current.commandQueue.enqueue(createMessageCommand("m2"));
173
+ });
174
+
175
+ await waitFor(() => {
176
+ expect(result.current.runBatchesRef.current).toHaveLength(2);
177
+ });
178
+ expect(consoleError).toHaveBeenCalledWith(
179
+ "[assistant-ui] Assistant transport onFinish callback threw an error",
180
+ error,
181
+ );
182
+ } finally {
183
+ consoleError.mockRestore();
184
+ }
185
+ });
186
+
149
187
  it("cancel returns combined in-flight and queued commands", async () => {
150
188
  const onCancel = vi.fn();
151
189
  const { result } = renderHook(() =>
@@ -92,7 +92,9 @@ export type AssistantTransportProtocol = "data-stream" | "assistant-transport";
92
92
 
93
93
  export type SendCommandsRequestBody = {
94
94
  commands: QueuedCommand[];
95
- state: unknown;
95
+ /** Absent on a resume with `resumeStateApi`; the server replays from its retained snapshot. */
96
+ state?: unknown;
97
+ runId?: string;
96
98
  system: string | undefined;
97
99
  tools: Record<string, unknown> | undefined;
98
100
  callSettings: LanguageModelV1CallSettings | undefined;
@@ -109,6 +111,8 @@ export type AssistantTransportOptions<T> = {
109
111
  initialState: T;
110
112
  api: string;
111
113
  resumeApi?: string;
114
+ /** Endpoint that returns the retained initial state and run ID for a resume stream. A 204 response means no run is active and the resume is skipped. */
115
+ resumeStateApi?: string;
112
116
  protocol?: AssistantTransportProtocol;
113
117
  /**
114
118
  * When `false`, stream decoding and state reconciliation tolerate malformed
@@ -16,6 +16,13 @@ Command Scheduling
16
16
  - If no run is in progress: start a run immediately and flush commands to the server.
17
17
  - A follow-up run that finds an empty queue is a no-op: no request is sent and no error is surfaced.
18
18
  - A resume run sends no commands; commands enqueued while it is pending or active are flushed in a follow-up run after it settles.
19
+
20
+ Resume State
21
+
22
+ - With `resumeStateApi` configured, a resume first posts `{ threadId }` there; the endpoint returns `{ runId, state }`, the state that started the active run.
23
+ - The resume request carries `runId` and no `state`: the server replays from the snapshot it retained for that run, so neither `body` overrides nor a `prepareSendCommandsRequest` rebuild can substitute a different base. `runId` takes precedence over `body` fields and is re-attached after `prepareSendCommandsRequest` so a rebuilt body cannot drop it. The local base is replaced by the snapshot only after the resume stream responds OK, so a rejected resume keeps the local state.
24
+ - A 204 response means no active run: the resume is skipped without error, and queued commands are flushed in a follow-up run.
25
+ - A malformed snapshot response fails the resume before the replay request is sent.
19
26
  - Runs execute on a `queueMicrotask`, so multiple synchronous enqueues coalesce into a single request: the first run's flush takes all of them, and the coalesced follow-up run no-ops.
20
27
 
21
28
  Command Queue
@@ -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
  });
@@ -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 ext = useAuiState((s) => {
23
- const parts = s.attachment.name.split(".");
24
- return parts.length > 1 ? parts.pop()! : "";
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
- .{ext}
32
+ {props.children ?? label}
29
33
  </Primitive.div>
30
34
  );
31
35
  });