@assistant-ui/react 0.15.3 → 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 +11 -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 +64 -18
- 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 +7 -7
- 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 +11 -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 +321 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +88 -18
- 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
|
@@ -92,7 +92,9 @@ export type AssistantTransportProtocol = "data-stream" | "assistant-transport";
|
|
|
92
92
|
|
|
93
93
|
export type SendCommandsRequestBody = {
|
|
94
94
|
commands: QueuedCommand[];
|
|
95
|
-
|
|
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,7 +111,15 @@ 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;
|
|
117
|
+
/**
|
|
118
|
+
* When `false`, stream decoding and state reconciliation tolerate malformed
|
|
119
|
+
* input (invalid chunks are dropped with a console log) instead of throwing.
|
|
120
|
+
* Resume runs always decode leniently. Defaults to `true`.
|
|
121
|
+
*/
|
|
122
|
+
strict?: boolean;
|
|
113
123
|
converter: AssistantTransportStateConverter<T>;
|
|
114
124
|
headers: HeadersValue | (() => Promise<HeadersValue>);
|
|
115
125
|
body?: object | (() => Promise<object | undefined>);
|
|
@@ -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
|
package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx
CHANGED
|
@@ -112,6 +112,7 @@ const mountRuntime = (
|
|
|
112
112
|
|
|
113
113
|
afterEach(() => {
|
|
114
114
|
vi.unstubAllGlobals();
|
|
115
|
+
vi.restoreAllMocks();
|
|
115
116
|
});
|
|
116
117
|
|
|
117
118
|
describe("useAssistantTransportRuntime", () => {
|
|
@@ -136,6 +137,7 @@ describe("useAssistantTransportRuntime", () => {
|
|
|
136
137
|
expect(
|
|
137
138
|
fetchMock.requests[0]!.body["commands"].map((c: any) => c.type),
|
|
138
139
|
).toEqual(["add-message", "add-message"]);
|
|
140
|
+
expect(fetchMock.requests[0]!.body["state"]).toEqual({});
|
|
139
141
|
|
|
140
142
|
act(() => fetchMock.servers[0]!.close());
|
|
141
143
|
|
|
@@ -145,6 +147,36 @@ describe("useAssistantTransportRuntime", () => {
|
|
|
145
147
|
expect(fetchMock.requests).toHaveLength(1);
|
|
146
148
|
});
|
|
147
149
|
|
|
150
|
+
it("skips add-message commands with no supported parts", async () => {
|
|
151
|
+
const fetchMock = installFetch();
|
|
152
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
153
|
+
const { aui, sendCommand } = mountRuntime();
|
|
154
|
+
await waitFor(() =>
|
|
155
|
+
expect(
|
|
156
|
+
(aui().thread.getState().extras as { sendCommand?: unknown })
|
|
157
|
+
?.sendCommand,
|
|
158
|
+
).toBeTypeOf("function"),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
act(() =>
|
|
162
|
+
aui().thread.append({
|
|
163
|
+
role: "user",
|
|
164
|
+
content: [{ type: "audio", audio: { data: "", format: "mp3" } }],
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
await act(async () => {});
|
|
169
|
+
expect(warn).toHaveBeenCalledWith(
|
|
170
|
+
"[assistant-ui] Skipped add-message command with no supported parts",
|
|
171
|
+
);
|
|
172
|
+
expect(fetchMock.requests).toHaveLength(0);
|
|
173
|
+
|
|
174
|
+
// The skipped message must not leak its parentId into later batches.
|
|
175
|
+
act(() => sendCommand(createMessageCommand("follow-up")));
|
|
176
|
+
await waitFor(() => expect(fetchMock.requests).toHaveLength(1));
|
|
177
|
+
expect(fetchMock.requests[0]!.body).not.toHaveProperty("parentId");
|
|
178
|
+
});
|
|
179
|
+
|
|
148
180
|
it("flushes commands enqueued during a resume run in a follow-up run", async () => {
|
|
149
181
|
const fetchMock = installFetch();
|
|
150
182
|
const { aui, sendCommand } = mountRuntime({
|
|
@@ -170,6 +202,7 @@ describe("useAssistantTransportRuntime", () => {
|
|
|
170
202
|
await waitFor(() => expect(fetchMock.requests).toHaveLength(2));
|
|
171
203
|
expect(fetchMock.requests[1]!.url).toBe("https://example.com/resume");
|
|
172
204
|
expect(fetchMock.requests[1]!.body["commands"]).toEqual([]);
|
|
205
|
+
expect(fetchMock.requests[1]!.body).toHaveProperty("state");
|
|
173
206
|
|
|
174
207
|
// "b" coalesced into the resume run and must not starve in the queue.
|
|
175
208
|
act(() => fetchMock.servers[1]!.close());
|
|
@@ -182,4 +215,292 @@ describe("useAssistantTransportRuntime", () => {
|
|
|
182
215
|
act(() => fetchMock.servers[2]!.close());
|
|
183
216
|
await waitFor(() => expect(aui().thread.getState().isRunning).toBe(false));
|
|
184
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
|
+
});
|
|
185
506
|
});
|
package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type { UserExternalState } from "../../../augmentations";
|
|
|
42
42
|
|
|
43
43
|
const convertAppendMessageToCommand = (
|
|
44
44
|
message: AppendMessage,
|
|
45
|
-
): AddMessageCommand => {
|
|
45
|
+
): AddMessageCommand | null => {
|
|
46
46
|
if (message.role !== "user")
|
|
47
47
|
throw new Error("Only user messages are supported");
|
|
48
48
|
|
|
@@ -59,6 +59,8 @@ const convertAppendMessageToCommand = (
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
if (parts.length === 0) return null;
|
|
63
|
+
|
|
62
64
|
return {
|
|
63
65
|
type: "add-message",
|
|
64
66
|
message: {
|
|
@@ -70,6 +72,36 @@ const convertAppendMessageToCommand = (
|
|
|
70
72
|
};
|
|
71
73
|
};
|
|
72
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
|
+
|
|
73
105
|
const symbolAssistantTransportExtras = Symbol("assistant-transport-extras");
|
|
74
106
|
type AssistantTransportExtras = {
|
|
75
107
|
[symbolAssistantTransportExtras]: true;
|
|
@@ -127,6 +159,20 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
127
159
|
onQueue: () => runManager.schedule(),
|
|
128
160
|
});
|
|
129
161
|
|
|
162
|
+
const enqueueAppendMessage = (message: AppendMessage) => {
|
|
163
|
+
const command = convertAppendMessageToCommand(message);
|
|
164
|
+
if (!command) {
|
|
165
|
+
console.warn(
|
|
166
|
+
"[assistant-ui] Skipped add-message command with no supported parts",
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
parentIdRef.current = message.parentId;
|
|
171
|
+
commandQueue.enqueue(command, {
|
|
172
|
+
schedule: message.startRun ?? message.role === "user",
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
130
176
|
const threadId = useAuiState((s) => s.threadListItem.remoteId);
|
|
131
177
|
|
|
132
178
|
const runManager = useRunManager({
|
|
@@ -144,6 +190,24 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
144
190
|
if (!isResume) parentIdRef.current = undefined;
|
|
145
191
|
|
|
146
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
|
+
|
|
147
211
|
const bodyValue =
|
|
148
212
|
typeof options.body === "function"
|
|
149
213
|
? await options.body()
|
|
@@ -152,7 +216,7 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
152
216
|
|
|
153
217
|
let requestBody: Record<string, unknown> = {
|
|
154
218
|
commands,
|
|
155
|
-
state: agentStateRef.current,
|
|
219
|
+
...(resumeState === undefined && { state: agentStateRef.current }),
|
|
156
220
|
system: context.system,
|
|
157
221
|
tools: context.tools ? toToolsJSONSchema(context.tools) : undefined,
|
|
158
222
|
threadId,
|
|
@@ -174,6 +238,14 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
174
238
|
);
|
|
175
239
|
}
|
|
176
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
|
+
|
|
177
249
|
const response = await fetch(
|
|
178
250
|
isResume ? options.resumeApi! : options.api,
|
|
179
251
|
{
|
|
@@ -194,6 +266,11 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
194
266
|
throw new Error("Response body is null");
|
|
195
267
|
}
|
|
196
268
|
|
|
269
|
+
if (resumeState !== undefined) {
|
|
270
|
+
agentStateRef.current = resumeState.state;
|
|
271
|
+
rerender((prev) => prev + 1);
|
|
272
|
+
}
|
|
273
|
+
|
|
197
274
|
const body = await createReplayBoundaryStream(response, {
|
|
198
275
|
setReplaying: setIsReplaying,
|
|
199
276
|
waitForRender: waitForReplayRender,
|
|
@@ -201,10 +278,12 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
201
278
|
|
|
202
279
|
// Select decoder based on protocol option
|
|
203
280
|
const protocol = options.protocol ?? "data-stream";
|
|
281
|
+
// Resume replays a best-effort buffer; always reconcile leniently.
|
|
282
|
+
const strict = isResume ? false : (options.strict ?? true);
|
|
204
283
|
const decoder =
|
|
205
284
|
protocol === "assistant-transport"
|
|
206
|
-
? new AssistantTransportDecoder()
|
|
207
|
-
: new DataStreamDecoder();
|
|
285
|
+
? new AssistantTransportDecoder({ strict })
|
|
286
|
+
: new DataStreamDecoder({ strict });
|
|
208
287
|
|
|
209
288
|
let err: string | undefined;
|
|
210
289
|
const stream = body.pipeThrough(decoder).pipeThrough(
|
|
@@ -214,6 +293,7 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
214
293
|
(agentStateRef.current as ReadonlyJSONValue) ?? null,
|
|
215
294
|
}),
|
|
216
295
|
throttle: isResume,
|
|
296
|
+
strict,
|
|
217
297
|
onError: (error) => {
|
|
218
298
|
err = error;
|
|
219
299
|
},
|
|
@@ -329,21 +409,11 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
329
409
|
},
|
|
330
410
|
state: agentStateRef.current as UserExternalState,
|
|
331
411
|
} satisfies AssistantTransportExtras,
|
|
332
|
-
onNew: async (message: AppendMessage): Promise<void> =>
|
|
333
|
-
|
|
334
|
-
const command = convertAppendMessageToCommand(message);
|
|
335
|
-
commandQueue.enqueue(command, {
|
|
336
|
-
schedule: message.startRun ?? message.role === "user",
|
|
337
|
-
});
|
|
338
|
-
},
|
|
412
|
+
onNew: async (message: AppendMessage): Promise<void> =>
|
|
413
|
+
enqueueAppendMessage(message),
|
|
339
414
|
...(options.capabilities?.edit && {
|
|
340
|
-
onEdit: async (message: AppendMessage): Promise<void> =>
|
|
341
|
-
|
|
342
|
-
const command = convertAppendMessageToCommand(message);
|
|
343
|
-
commandQueue.enqueue(command, {
|
|
344
|
-
schedule: message.startRun ?? message.role === "user",
|
|
345
|
-
});
|
|
346
|
-
},
|
|
415
|
+
onEdit: async (message: AppendMessage): Promise<void> =>
|
|
416
|
+
enqueueAppendMessage(message),
|
|
347
417
|
}),
|
|
348
418
|
...(commandQueue.state.queued.length > 0 && {
|
|
349
419
|
onReload: async (parentId: string | null) => {
|
|
@@ -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
|
});
|