@assistant-ui/react-ag-ui 0.1.3 → 0.1.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.
@@ -300,22 +300,35 @@ const replaceTextParts = (
300
300
  return result;
301
301
  };
302
302
 
303
+ /**
304
+ * Settles nested sub-agent messages with `status`. Without `runSubagentIds`,
305
+ * only running messages settle. With it, only that run's sub-agent messages
306
+ * settle, and an incomplete `status` also settles one still waiting on its own
307
+ * tool call: the run that could answer it has ended.
308
+ */
303
309
  const settleNestedContent = (
304
310
  content: ThreadAssistantMessage["content"],
305
311
  status: MessageStatus,
312
+ runSubagentIds?: ReadonlySet<string>,
306
313
  ) =>
307
- mapToolCallPartsDeep(content, (part) =>
308
- part.messages
309
- ? {
310
- ...part,
311
- messages: part.messages.map((message) =>
312
- message.role === "assistant" && message.status?.type === "running"
313
- ? { ...message, status }
314
- : message,
315
- ),
316
- }
317
- : part,
318
- ).content;
314
+ mapToolCallPartsDeep(content, (part) => {
315
+ if (!part.messages) return part;
316
+ let changed = false;
317
+ const messages = part.messages.map((message) => {
318
+ if (message.role !== "assistant") return message;
319
+ const settles = !runSubagentIds
320
+ ? message.status?.type === "running"
321
+ : runSubagentIds.has(message.id) &&
322
+ (message.status?.type === "running" ||
323
+ (status.type === "incomplete" &&
324
+ message.status?.type === "requires-action" &&
325
+ message.status.reason === "tool-calls"));
326
+ if (!settles) return message;
327
+ changed = true;
328
+ return { ...message, status };
329
+ });
330
+ return changed ? { ...part, messages } : part;
331
+ }).content;
319
332
 
320
333
  type AgUiRuntimeCallbackName = "onError" | "onCancel";
321
334
 
@@ -392,6 +405,8 @@ export class AgUiThreadController {
392
405
  // The agent that started the active run. updateOptions can swap this.agent
393
406
  // mid-run, and cancelling has to reach the agent holding the live request.
394
407
  private activeRunAgent: AbstractAgent | null = null;
408
+ // Counts runs started, so a finished run can tell a newer one began.
409
+ private runCount = 0;
395
410
  private history: ThreadHistoryAdapter | undefined;
396
411
  private readonly assistantHistoryParents = new Map<string, string | null>();
397
412
  private readonly subagentParentToolCallIds = new Map<
@@ -400,6 +415,9 @@ export class AgUiThreadController {
400
415
  >();
401
416
  private readonly snapshotHistoryIds = new Set<string>();
402
417
  private readonly persistedHistoryIds = new Set<string>();
418
+ // Ids whose append has not succeeded yet. A write queued behind that append
419
+ // runs as an append too, since an update may not move the stored head.
420
+ private readonly unappendedHistoryIds = new Set<string>();
403
421
  private readonly historyWrites = createHistoryWriteChain();
404
422
  private _historyImportPromise: Promise<void> | undefined;
405
423
  private _loadPromise: Promise<void> | undefined;
@@ -525,6 +543,7 @@ export class AgUiThreadController {
525
543
  this.subagentParentToolCallIds.clear();
526
544
  this.snapshotHistoryIds.clear();
527
545
  this.persistedHistoryIds.clear();
546
+ this.unappendedHistoryIds.clear();
528
547
  for (const { message } of repo.messages) {
529
548
  this.persistedHistoryIds.add(message.id);
530
549
  }
@@ -683,6 +702,17 @@ export class AgUiThreadController {
683
702
  await this.startRun(parentId, config.runConfig);
684
703
  }
685
704
 
705
+ /** Deletes a message that has no replies under it, and its stored history entry. */
706
+ async deleteMessage(messageId: string): Promise<void> {
707
+ const item = this.session.tryGetMessage(messageId);
708
+ if (!item) return;
709
+ const parentId = item.parentId ?? null;
710
+ this.session.deleteMessage(messageId, parentId);
711
+ this.snapshotHistoryIds.delete(messageId);
712
+ this.publishUpdate();
713
+ await this.history?.delete?.([{ parentId, message: item.message }]);
714
+ }
715
+
686
716
  async cancel(): Promise<void> {
687
717
  this.dispatch({
688
718
  type: "historyMutationAdvanced",
@@ -1721,6 +1751,8 @@ export class AgUiThreadController {
1721
1751
  const assistantParentId = parent ? parentId : this.session.headId;
1722
1752
  let assistantMessageId: string | undefined;
1723
1753
  let snapshotAssistantId: string | undefined;
1754
+ const importedRunAssistants = new Set<string>();
1755
+ const runSubagentIds = new Set<string>();
1724
1756
  let replayInterruptMessageId: string | undefined;
1725
1757
  // A snapshot the preserve gate declines still evicts the in-flight
1726
1758
  // assistant; recreating under the cached id on the next content-bearing
@@ -2263,9 +2295,38 @@ export class AgUiThreadController {
2263
2295
  adoptServerMessageId(serverId, true, frameOpenIds);
2264
2296
  },
2265
2297
  });
2298
+ const settleRunSubagents = (status: MessageStatus) => {
2299
+ if (runSubagentIds.size === 0) return;
2300
+ for (const message of this.session.getMessages()) {
2301
+ if (message.role !== "assistant") continue;
2302
+ const content = settleNestedContent(
2303
+ message.content,
2304
+ status,
2305
+ runSubagentIds,
2306
+ );
2307
+ if (content === message.content) continue;
2308
+ this.updateAssistantMessage(message.id, { content });
2309
+ this.persistSettledAssistant(message.id);
2310
+ }
2311
+ };
2312
+ // Writes this run's other assistants in branch order: before the answer
2313
+ // settles, the ones above it; after, the ones below. An adapter that
2314
+ // moves its head on each first write then ends on the branch's tail.
2315
+ const persistRunAssistants = (aboveAnswer: boolean) => {
2316
+ for (const message of this.session.getMessages()) {
2317
+ if (message.id === assistantMessageId) {
2318
+ if (aboveAnswer) return;
2319
+ continue;
2320
+ }
2321
+ if (message.role === "assistant" && !runStartMessageIds.has(message.id))
2322
+ this.persistAssistantHistory(message.id);
2323
+ }
2324
+ };
2266
2325
  let runFinished = false;
2267
2326
  const dispatch = (event: AgUiEvent) => {
2268
2327
  if (this.abortController !== abortController) return;
2328
+ if (event.type === "SUBAGENT_STARTED")
2329
+ runSubagentIds.add(event.subagentRunId);
2269
2330
  if (event.type === "RUN_FINISHED") {
2270
2331
  runFinished = true;
2271
2332
  if (event.outcome?.type === "interrupt")
@@ -2288,6 +2349,7 @@ export class AgUiThreadController {
2288
2349
  event.type === "RUN_FINISHED" ||
2289
2350
  event.type === "RUN_ERROR" ||
2290
2351
  event.type === "RUN_CANCELLED";
2352
+ if (settlingRun) persistRunAssistants(true);
2291
2353
  const beforeSnapshot =
2292
2354
  event.type === "MESSAGES_SNAPSHOT" ? this.session.getMessages() : [];
2293
2355
  try {
@@ -2301,6 +2363,31 @@ export class AgUiThreadController {
2301
2363
  candidateContexts.size ? reconcileReplaySnapshot : undefined,
2302
2364
  );
2303
2365
  if (event.type === "MESSAGES_SNAPSHOT") {
2366
+ // Assistants a snapshot adds belong to the run's history even when
2367
+ // the run never streams into them. Every import resets the pending
2368
+ // marks, so each snapshot marks them again. The import also routes
2369
+ // every message to update; a run assistant this client never wrote
2370
+ // gets an append first, which is what moves the stored head.
2371
+ const previousIds = new Set(beforeSnapshot.map(({ id }) => id));
2372
+ for (const message of this.session.getMessages()) {
2373
+ if (
2374
+ message.role !== "assistant" ||
2375
+ runStartMessageIds.has(message.id)
2376
+ )
2377
+ continue;
2378
+ if (!previousIds.has(message.id))
2379
+ importedRunAssistants.add(message.id);
2380
+ if (
2381
+ !this.persistedHistoryIds.has(message.id) &&
2382
+ this.historyWrites.pending(message.id) === undefined
2383
+ )
2384
+ this.snapshotHistoryIds.delete(message.id);
2385
+ }
2386
+ for (const messageId of importedRunAssistants) {
2387
+ const item = this.session.tryGetMessage(messageId);
2388
+ if (item)
2389
+ this.markPendingAssistantHistory(messageId, item.parentId);
2390
+ }
2304
2391
  for (const message of beforeSnapshot) {
2305
2392
  const context = replayContexts.get(message.id);
2306
2393
  const current = this.session.tryGetMessage(message.id)?.message;
@@ -2332,6 +2419,16 @@ export class AgUiThreadController {
2332
2419
  tail?.role === "assistant" ? tail.id : undefined;
2333
2420
  }
2334
2421
  }
2422
+ if (event.type === "RUN_ERROR") {
2423
+ settleRunSubagents({
2424
+ type: "incomplete",
2425
+ reason: "error",
2426
+ ...(event.message !== undefined ? { error: event.message } : {}),
2427
+ });
2428
+ } else if (event.type === "RUN_CANCELLED") {
2429
+ settleRunSubagents({ type: "incomplete", reason: "cancelled" });
2430
+ }
2431
+ if (settlingRun) persistRunAssistants(false);
2335
2432
  } finally {
2336
2433
  settlingRun = false;
2337
2434
  }
@@ -2340,6 +2437,7 @@ export class AgUiThreadController {
2340
2437
  this.abortController = abortController;
2341
2438
  const runAgentInstance = this.agent;
2342
2439
  this.activeRunAgent = runAgentInstance;
2440
+ const runNumber = ++this.runCount;
2343
2441
 
2344
2442
  let cancelRun = () => dispatch({ type: "RUN_CANCELLED" });
2345
2443
  abortSignal.addEventListener(
@@ -2423,6 +2521,7 @@ export class AgUiThreadController {
2423
2521
  current?.role === "assistant" &&
2424
2522
  current.status?.type === "running"
2425
2523
  ) {
2524
+ persistRunAssistants(true);
2426
2525
  // Finalization is transport cleanup, not proof of a terminal event.
2427
2526
  const status: MessageStatus = {
2428
2527
  type: "incomplete",
@@ -2435,6 +2534,25 @@ export class AgUiThreadController {
2435
2534
  content: settleNestedContent(current.content, status),
2436
2535
  });
2437
2536
  }
2537
+ if (this.abortController === abortController && !runFinished) {
2538
+ // The run ended without RUN_FINISHED, so no sub-agent call it left
2539
+ // open can still be answered.
2540
+ const settled = assistantMessageId
2541
+ ? this.session.tryGetMessage(assistantMessageId)?.message
2542
+ : undefined;
2543
+ settleRunSubagents(
2544
+ settled?.status?.type === "incomplete"
2545
+ ? settled.status
2546
+ : {
2547
+ type: "incomplete",
2548
+ reason: "error",
2549
+ error: "The connection ended before the answer finished.",
2550
+ },
2551
+ );
2552
+ }
2553
+ // Stop may have finished this run already; a newer run owns its own
2554
+ // writes.
2555
+ if (this.runCount === runNumber) persistRunAssistants(false);
2438
2556
  this.finishRun(abortController);
2439
2557
  }
2440
2558
 
@@ -2773,11 +2891,14 @@ export class AgUiThreadController {
2773
2891
  oldId,
2774
2892
  collidesWithExisting ? undefined : newId,
2775
2893
  );
2894
+ if (this.unappendedHistoryIds.delete(oldId) && !collidesWithExisting)
2895
+ this.unappendedHistoryIds.add(newId);
2776
2896
  if (pendingWrite && !collidesWithExisting) {
2777
2897
  void pendingWrite.then(
2778
2898
  () => {
2779
2899
  this.persistedHistoryIds.delete(oldId);
2780
2900
  this.persistedHistoryIds.add(newId);
2901
+ this.unappendedHistoryIds.delete(newId);
2781
2902
  },
2782
2903
  () => {},
2783
2904
  );
@@ -3859,7 +3980,9 @@ export class AgUiThreadController {
3859
3980
 
3860
3981
  if (shouldUpdate) {
3861
3982
  const write = this.historyWrites.chain(messageId, () =>
3862
- update.call(history, { parentId, message }),
3983
+ this.unappendedHistoryIds.has(messageId)
3984
+ ? this.appendOnce(history, messageId, { parentId, message })
3985
+ : update.call(history, { parentId, message }),
3863
3986
  );
3864
3987
  this.assistantHistoryParents.delete(messageId);
3865
3988
  void write.then(
@@ -3897,6 +4020,15 @@ export class AgUiThreadController {
3897
4020
  );
3898
4021
  }
3899
4022
 
4023
+ private async appendOnce(
4024
+ history: ThreadHistoryAdapter,
4025
+ messageId: string,
4026
+ item: { parentId: string | null; message: ThreadMessage },
4027
+ ): Promise<void> {
4028
+ await history.append(item);
4029
+ this.unappendedHistoryIds.delete(messageId);
4030
+ }
4031
+
3900
4032
  private appendHistoryItem(
3901
4033
  parentId: string | null,
3902
4034
  message: ThreadMessage,
@@ -3905,9 +4037,10 @@ export class AgUiThreadController {
3905
4037
  const pending = this.historyWrites.pending(message.id);
3906
4038
  if (pending) return pending;
3907
4039
 
3908
- const append = this.history.append.bind(this.history);
4040
+ const history = this.history;
4041
+ this.unappendedHistoryIds.add(message.id);
3909
4042
  const write = this.historyWrites.chain(message.id, () =>
3910
- append({ parentId, message }),
4043
+ this.appendOnce(history, message.id, { parentId, message }),
3911
4044
  );
3912
4045
  void write.then(
3913
4046
  () => {
@@ -4,11 +4,13 @@ import { act, cleanup, render, waitFor } from "@testing-library/react";
4
4
  import { HttpAgent, type RunAgentInput } from "@ag-ui/client";
5
5
  import {
6
6
  AssistantRuntimeProvider,
7
+ MessageByIndexProvider,
8
+ useActionBarResume,
7
9
  useCloudThreadListAdapter,
8
10
  useRemoteThreadListRuntime,
9
11
  } from "@assistant-ui/core/react";
10
12
  import type { AssistantRuntime } from "@assistant-ui/core";
11
- import { useAui, type AssistantClient } from "@assistant-ui/store";
13
+ import { useAui, useAuiState, type AssistantClient } from "@assistant-ui/store";
12
14
  import { AssistantCloud } from "assistant-cloud";
13
15
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
16
  import { useAgUiRuntime } from "./useAgUiRuntime";
@@ -107,6 +109,13 @@ const createServer = (
107
109
  if (row) row.content = body.content;
108
110
  return json({});
109
111
  }
112
+ if (method === "DELETE" && path === "/threads/remote-1/messages") {
113
+ for (const id of body.message_ids as string[]) {
114
+ const index = rows.findIndex((r) => r.id === id);
115
+ if (index !== -1) rows.splice(index, 1);
116
+ }
117
+ return json({});
118
+ }
110
119
  if (/^\/threads\/remote-1\/runs\/[^/]+\/heartbeat$/.test(path))
111
120
  return json({ heartbeat_interval_ms: heartbeatIntervalMs });
112
121
  if (/^\/threads\/remote-1\/runs\/[^/]+\/end$/.test(path)) return json({});
@@ -192,6 +201,20 @@ const renderApp = async (staleAfter?: number) => {
192
201
  aui = useAui();
193
202
  return null;
194
203
  };
204
+ const resume: { current: ReturnType<typeof useActionBarResume> | undefined } =
205
+ { current: undefined };
206
+ const CaptureResume = () => {
207
+ resume.current = useActionBarResume();
208
+ return null;
209
+ };
210
+ const ReplyResume = () => {
211
+ const hasReply = useAuiState((s) => s.thread.messages.length >= 2);
212
+ return hasReply ? (
213
+ <MessageByIndexProvider index={1}>
214
+ <CaptureResume />
215
+ </MessageByIndexProvider>
216
+ ) : null;
217
+ };
195
218
  const App = () => {
196
219
  const adapter = useCloudThreadListAdapter({
197
220
  cloud,
@@ -206,6 +229,7 @@ const renderApp = async (staleAfter?: number) => {
206
229
  return (
207
230
  <AssistantRuntimeProvider runtime={runtime}>
208
231
  <Capture />
232
+ <ReplyResume />
209
233
  </AssistantRuntimeProvider>
210
234
  );
211
235
  };
@@ -213,19 +237,25 @@ const renderApp = async (staleAfter?: number) => {
213
237
  await act(async () => {
214
238
  await aui.threads.getLoadThreadsPromise();
215
239
  });
216
- return { agent, getAui: () => aui, getRuntime: () => runtime };
240
+ return {
241
+ agent,
242
+ getAui: () => aui,
243
+ getRuntime: () => runtime,
244
+ getResume: () => resume.current,
245
+ };
217
246
  };
218
247
 
219
248
  const assistantRow = (
220
249
  last_heartbeat_ms_ago: number,
221
250
  status: "running" | "ended" = "running",
251
+ content: readonly unknown[] = [{ type: "text", text: "half an answer" }],
222
252
  ): Row => ({
223
253
  id: "a1",
224
254
  parent_id: "u1",
225
255
  format: "aui/v0",
226
256
  content: {
227
257
  role: "assistant",
228
- content: [{ type: "text", text: "half an answer" }],
258
+ content: [...content],
229
259
  status: { type: "incomplete", reason: "cancelled" },
230
260
  },
231
261
  external_id: "a1",
@@ -304,7 +334,50 @@ describe("useAgUiRuntime with Assistant Cloud run leases", () => {
304
334
  expect(reply.id).toBe("agent-msg-1");
305
335
  });
306
336
 
307
- it("shows a reloaded run quiet for longer than staleAfter as interrupted; Resume posts a sibling with its own lease and runs the agent again", async () => {
337
+ it("shows a reloaded empty run quiet for longer than staleAfter as stale; Resume deletes it and runs the agent again from the user message with no sibling", async () => {
338
+ useServer([userRow, assistantRow(45_000, "running", [])]);
339
+ const { agent, getAui, getRuntime, getResume } = await renderApp();
340
+ await act(async () => {
341
+ await getRuntime().threads.switchToThread("remote-1");
342
+ });
343
+ await waitFor(() =>
344
+ expect(getAui().thread.message({ index: 1 }).getState().runState).toBe(
345
+ "stale",
346
+ ),
347
+ );
348
+ await waitFor(() => expect(getResume()?.disabled).toBe(false));
349
+
350
+ await act(async () => {
351
+ getResume()!.resume();
352
+ });
353
+ await waitFor(() => expect(agent.runs).toHaveLength(1));
354
+ await waitFor(() =>
355
+ expect(createsWith(server.calls, true)).toHaveLength(1),
356
+ );
357
+ expect(createsWith(server.calls, true)[0]!.body).toMatchObject({
358
+ parent_id: "u1",
359
+ open_run: true,
360
+ });
361
+ await act(async () => {
362
+ agent.runs[0]!.finish();
363
+ });
364
+ await waitFor(() =>
365
+ expect(
366
+ server.calls.some((c) => c.path.endsWith("/runs/lease_1/end")),
367
+ ).toBe(true),
368
+ );
369
+ const deletes = server.calls.filter((c) => c.method === "DELETE");
370
+ expect(deletes.map((c) => c.body)).toEqual([{ message_ids: ["a1"] }]);
371
+ expect(server.rows.map((r) => r.id)).toEqual(["u1", "msg_1"]);
372
+ const messages = getAui().thread.getState().messages;
373
+ expect(messages.map((m) => m.role)).toEqual(["user", "assistant"]);
374
+ expect(messages[1]!.id).not.toBe("a1");
375
+ expect(getAui().thread.message({ index: 1 }).getState().branchCount).toBe(
376
+ 1,
377
+ );
378
+ });
379
+
380
+ it("shows a reloaded run quiet for longer than staleAfter as interrupted; Regenerate posts a sibling with its own lease and runs the agent again", async () => {
308
381
  useServer([userRow, assistantRow(45_000)]);
309
382
  const { agent, getAui, getRuntime } = await renderApp();
310
383
  await act(async () => {
@@ -314,7 +387,12 @@ describe("useAgUiRuntime with Assistant Cloud run leases", () => {
314
387
  expect(getAui().thread.getState().messages).toHaveLength(2),
315
388
  );
316
389
  const stale = getAui().thread.message({ index: 1 });
317
- await waitFor(() => expect(stale.getState().runState).toBe("stale"));
390
+ await waitFor(() =>
391
+ expect(stale.getState().status).toMatchObject({
392
+ error: { code: "run_stale" },
393
+ }),
394
+ );
395
+ expect(stale.getState().runState).toBeUndefined();
318
396
 
319
397
  await act(async () => {
320
398
  stale.reload();
@@ -363,8 +441,8 @@ describe("useAgUiRuntime with Assistant Cloud run leases", () => {
363
441
  });
364
442
  await waitFor(() =>
365
443
  expect(
366
- short.getAui().thread.message({ index: 1 }).getState().runState,
367
- ).toBe("stale"),
444
+ short.getAui().thread.message({ index: 1 }).getState().status,
445
+ ).toMatchObject({ error: { code: "run_stale" } }),
368
446
  );
369
447
  });
370
448
 
@@ -345,6 +345,7 @@ export function useAgUiRuntime(
345
345
  queueController?.clear();
346
346
  return core.reload(parentId, config);
347
347
  },
348
+ onDelete: (messageId: string) => core.deleteMessage(messageId),
348
349
  onCancel: async () => {
349
350
  queueController?.notifyCancelled();
350
351
  core.cancel();