@assistant-ui/react-ag-ui 0.1.7 → 0.1.9

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.
@@ -407,6 +407,123 @@ describe("fromAgUiSnapshotMessages subagent attribution", () => {
407
407
  ]);
408
408
  });
409
409
 
410
+ it("retains each nested record's pending-call admission when adjacent records are joined", () => {
411
+ const converted = fromAgUiSnapshotMessages(
412
+ [
413
+ { id: "a", role: "assistant", content: "", toolCalls: [call("spawn")] },
414
+ { id: "t", role: "tool", toolCallId: "spawn", content: '"ok"' },
415
+ {
416
+ id: "s1",
417
+ role: "assistant",
418
+ content: "",
419
+ toolCalls: [call("one"), call("excluded")],
420
+ subagentRunId: "run",
421
+ metadata: {
422
+ custom: { agui: { pendingToolCallIds: ["one", "future"] } },
423
+ },
424
+ },
425
+ {
426
+ id: "s2",
427
+ role: "assistant",
428
+ content: "",
429
+ toolCalls: [call("two")],
430
+ subagentRunId: "run",
431
+ metadata: { custom: { agui: { pendingToolCallIds: ["two"] } } },
432
+ },
433
+ ],
434
+ { joinAdjacentAssistantMessages: true },
435
+ new Map([["run", "spawn"]]),
436
+ ) as (Message & {
437
+ metadata?: { custom?: { agui?: { pendingToolCallIds?: string[] } } };
438
+ })[];
439
+ const nested = findCall(converted, "spawn")?.messages?.[0] as Message & {
440
+ metadata?: { custom?: { agui?: { pendingToolCallIds?: string[] } } };
441
+ };
442
+ expect(findCall(converted, "spawn")?.messages).toHaveLength(1);
443
+ expect(nested.metadata?.custom?.agui?.pendingToolCallIds).toEqual([
444
+ "one",
445
+ "future",
446
+ "two",
447
+ ]);
448
+ expect(converted[0]?.metadata?.custom?.agui?.pendingToolCallIds).toEqual([
449
+ "one",
450
+ "future",
451
+ "two",
452
+ ]);
453
+ });
454
+
455
+ it.each([false, true])(
456
+ "does not merge pending lists while folding a nested run when joinAdjacentAssistantMessages is %s",
457
+ (joinAdjacentAssistantMessages) => {
458
+ // An empty record does not open a turn, so the adjacent-record join
459
+ // leaves both records for the subagent fold.
460
+ const converted = fromAgUiSnapshotMessages(
461
+ [
462
+ {
463
+ id: "a",
464
+ role: "assistant",
465
+ content: "",
466
+ toolCalls: [call("spawn")],
467
+ },
468
+ { id: "t", role: "tool", toolCallId: "spawn", content: '"ok"' },
469
+ {
470
+ id: "s1",
471
+ role: "assistant",
472
+ content: "",
473
+ subagentRunId: "run",
474
+ metadata: {
475
+ custom: {
476
+ agui: {
477
+ pendingToolCallIds: ["stale-a"],
478
+ interrupts: [{ id: "int-a", reason: "external" }],
479
+ },
480
+ },
481
+ },
482
+ },
483
+ {
484
+ id: "s2",
485
+ role: "assistant",
486
+ content: "done",
487
+ subagentRunId: "run",
488
+ metadata: {
489
+ custom: { agui: { pendingToolCallIds: ["stale-b"] } },
490
+ },
491
+ },
492
+ ],
493
+ { joinAdjacentAssistantMessages },
494
+ new Map([["run", "spawn"]]),
495
+ ) as (Message & {
496
+ metadata?: {
497
+ custom?: {
498
+ agui?: {
499
+ pendingToolCallIds?: string[];
500
+ interrupts?: { id: string }[];
501
+ };
502
+ };
503
+ };
504
+ })[];
505
+ const nested = findCall(converted, "spawn")?.messages?.[0] as Message & {
506
+ metadata?: {
507
+ custom?: {
508
+ agui?: {
509
+ pendingToolCallIds?: string[];
510
+ interrupts?: { id: string }[];
511
+ };
512
+ };
513
+ };
514
+ };
515
+ expect(texts(nested)).toEqual(["done"]);
516
+ expect(
517
+ nested.metadata?.custom?.agui?.interrupts?.map(
518
+ (interrupt) => interrupt.id,
519
+ ),
520
+ ).toEqual(["int-a"]);
521
+ expect(nested.metadata?.custom?.agui?.pendingToolCallIds).toEqual([
522
+ "stale-b",
523
+ ]);
524
+ },
525
+ );
526
+
410
527
  it("keeps a deeply nested pending call visible through resolved spawning calls", () => {
411
528
  const messages = [
412
529
  { id: "a1", role: "assistant", content: "", toolCalls: [call("top")] },
@@ -1078,6 +1078,7 @@ function readNamespacedMetadata(metadata: unknown): Record<string, unknown> {
1078
1078
  function foldTurnRecords(
1079
1079
  previous: CoreThreadMessageLike,
1080
1080
  message: CoreThreadMessageLike,
1081
+ mergePendingToolCallIds: boolean,
1081
1082
  ): CoreThreadMessageLike {
1082
1083
  const interrupts = [
1083
1084
  ...(readPersistedInterrupts(previous.metadata) ?? []),
@@ -1095,10 +1096,21 @@ function foldTurnRecords(
1095
1096
  trailsCalls ? { ...entry, after: true } : entry,
1096
1097
  ),
1097
1098
  ];
1099
+ const previousPending = readPersistedPendingToolCallIds(previous.metadata);
1100
+ const messagePending = readPersistedPendingToolCallIds(message.metadata);
1101
+ const pendingToolCallIds =
1102
+ !mergePendingToolCallIds ||
1103
+ (previousPending === undefined && messagePending === undefined)
1104
+ ? []
1105
+ : [
1106
+ ...admittedUnresolvedToolCallIds(previous, false),
1107
+ ...admittedUnresolvedToolCallIds(message, false),
1108
+ ];
1098
1109
  const namespaced = {
1099
1110
  ...readNamespacedMetadata(previous.metadata),
1100
1111
  ...readNamespacedMetadata(message.metadata),
1101
1112
  ...(interrupts.length > 0 ? { interrupts } : {}),
1113
+ ...(pendingToolCallIds.length > 0 ? { pendingToolCallIds } : {}),
1102
1114
  ...(opaqueReasoning.length > 0 ? { opaqueReasoning } : {}),
1103
1115
  } satisfies AgUiCustomMetadata & Record<string, unknown>;
1104
1116
  const custom = {
@@ -1134,6 +1146,16 @@ export type FromAgUiMessagesOptions = {
1134
1146
  * Matches the `showThinking` option of `useAgUiRuntime`. Defaults to `true`.
1135
1147
  */
1136
1148
  showThinking?: boolean;
1149
+ /**
1150
+ * Whether to join every run of adjacent assistant records into one message,
1151
+ * for a backend whose history splits one turn across records with ids of
1152
+ * their own. The merged message keeps the id of the record carrying the
1153
+ * answer, with the parts in wire order. Records that belong to separate runs
1154
+ * are joined too when no user message separates them. Matches the
1155
+ * `joinAdjacentAssistantMessages` option of `useAgUiRuntime`. Defaults to
1156
+ * `false`.
1157
+ */
1158
+ joinAdjacentAssistantMessages?: boolean;
1137
1159
  };
1138
1160
 
1139
1161
  const readSubagentRunId = (record: Record<string, unknown>) => {
@@ -1259,7 +1281,9 @@ export function fromAgUiSnapshotMessages(
1259
1281
  )
1260
1282
  .reduce<CoreThreadMessageLike | undefined>(
1261
1283
  (merged, message) =>
1262
- merged ? foldTurnRecords(merged, message) : message,
1284
+ // Pending-call admission is taken from the original records below.
1285
+ // Merging lists in this fold would union ids that admission skips.
1286
+ merged ? foldTurnRecords(merged, message, false) : message,
1263
1287
  undefined,
1264
1288
  );
1265
1289
  const content = Array.isArray(folded?.content) ? folded.content : [];
@@ -1418,6 +1442,8 @@ function convertAgUiMessages(
1418
1442
  options?: FromAgUiMessagesOptions,
1419
1443
  ): CoreThreadMessageLike[] {
1420
1444
  const showThinking = options?.showThinking ?? true;
1445
+ const joinAdjacentAssistantMessages =
1446
+ options?.joinAdjacentAssistantMessages ?? false;
1421
1447
  const converted: CoreThreadMessageLike[] = [];
1422
1448
  const a2uiBuckets = new Map<string, A2uiState>();
1423
1449
  const a2uiBucketOwnerIndices = new Map<string, number>();
@@ -1779,7 +1805,9 @@ function convertAgUiMessages(
1779
1805
  // a container beside the records around it, which the live run renders as
1780
1806
  // one message: the container joins the assistant record ahead of it, and
1781
1807
  // what follows a container joins it until the turn carries text, where a
1782
- // further text record opens a message of its own as it does live.
1808
+ // further text record opens a message of its own as it does live. An app
1809
+ // that knows its backend splits a turn across records it named itself opts
1810
+ // into joining every assistant record that follows one opening a turn.
1783
1811
  const folded: CoreThreadMessageLike[] = [];
1784
1812
  for (const message of converted) {
1785
1813
  const previous = folded[folded.length - 1];
@@ -1787,10 +1815,17 @@ function convertAgUiMessages(
1787
1815
  previous !== undefined &&
1788
1816
  (isSyntheticToolCallContainer(message)
1789
1817
  ? opensTurn(previous)
1790
- : isSyntheticToolCallContainer(previous) &&
1791
- answersToolCallContainer(message))
1818
+ : (isSyntheticToolCallContainer(previous) &&
1819
+ answersToolCallContainer(message)) ||
1820
+ (joinAdjacentAssistantMessages &&
1821
+ message.role === "assistant" &&
1822
+ opensTurn(previous)))
1792
1823
  ) {
1793
- folded[folded.length - 1] = foldTurnRecords(previous, message);
1824
+ folded[folded.length - 1] = foldTurnRecords(
1825
+ previous,
1826
+ message,
1827
+ joinAdjacentAssistantMessages,
1828
+ );
1794
1829
  continue;
1795
1830
  }
1796
1831
  folded.push(message);
@@ -94,6 +94,12 @@ export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
94
94
  logger?: Partial<Logger>;
95
95
  showThinking?: boolean;
96
96
  joinStrategy?: JoinStrategy;
97
+ /**
98
+ * Join adjacent assistant records of an imported snapshot into one message.
99
+ * See `FromAgUiMessagesOptions.joinAdjacentAssistantMessages`. Defaults to
100
+ * `false`.
101
+ */
102
+ joinAdjacentAssistantMessages?: boolean;
97
103
  /**
98
104
  * Return context entries for each run. `messages` is the exact AG-UI
99
105
  * transcript sent in the run input, after transcript selection and message
@@ -0,0 +1,169 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { useState } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
6
+ import {
7
+ HttpAgent,
8
+ runHttpRequest,
9
+ transformHttpEventStream,
10
+ type RunAgentInput,
11
+ } from "@ag-ui/client";
12
+ import type { ThreadMessage } from "@assistant-ui/core";
13
+ import { useAgUiRuntime } from "./useAgUiRuntime";
14
+
15
+ const encoder = new TextEncoder();
16
+ const frame = (event: object) =>
17
+ encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
18
+
19
+ // Each thread's run stays open until the request is aborted.
20
+ const openRun = (threadId: string, init?: RequestInit) =>
21
+ new Response(
22
+ new ReadableStream<Uint8Array>({
23
+ start(controller) {
24
+ controller.enqueue(
25
+ frame({ type: "RUN_STARTED", threadId, runId: `run-${threadId}` }),
26
+ );
27
+ controller.enqueue(
28
+ frame({
29
+ type: "TEXT_MESSAGE_START",
30
+ messageId: `answer-${threadId}`,
31
+ role: "assistant",
32
+ }),
33
+ );
34
+ controller.enqueue(
35
+ frame({
36
+ type: "TEXT_MESSAGE_CONTENT",
37
+ messageId: `answer-${threadId}`,
38
+ delta: `streaming ${threadId}`,
39
+ }),
40
+ );
41
+ init?.signal?.addEventListener("abort", () => controller.close(), {
42
+ once: true,
43
+ });
44
+ },
45
+ }),
46
+ { headers: { "Content-Type": "text/event-stream" } },
47
+ );
48
+
49
+ class ReconnectingAgent extends HttpAgent {
50
+ readonly connected: string[] = [];
51
+
52
+ override connectAgent(...args: Parameters<HttpAgent["connectAgent"]>) {
53
+ this.abortController = new AbortController();
54
+ return super.connectAgent(...args);
55
+ }
56
+
57
+ protected override connect(input: RunAgentInput) {
58
+ this.connected.push(input.threadId);
59
+ return transformHttpEventStream(
60
+ runHttpRequest(() =>
61
+ this.fetch(`${this.url}/${input.threadId}/stream`, {
62
+ signal: this.abortController.signal,
63
+ }),
64
+ ),
65
+ );
66
+ }
67
+ }
68
+
69
+ const question = (id: string): ThreadMessage => ({
70
+ id: `question-${id}`,
71
+ role: "user",
72
+ content: [{ type: "text", text: `asked in ${id}` }],
73
+ attachments: [],
74
+ createdAt: new Date(0),
75
+ metadata: { custom: {} },
76
+ });
77
+
78
+ afterEach(() => {
79
+ cleanup();
80
+ });
81
+
82
+ function renderDurable() {
83
+ const agent = new ReconnectingAgent({
84
+ url: "https://agent.test",
85
+ threadId: "initial",
86
+ fetch: async (url: string, init?: RequestInit) =>
87
+ openRun(new URL(url).pathname.split("/")[1]!, init),
88
+ });
89
+ const abortRun = vi.spyOn(agent, "abortRun");
90
+ const detachActiveRun = vi.spyOn(agent, "detachActiveRun");
91
+ const view = renderHook(() => {
92
+ const [threadId, setThreadId] = useState("initial");
93
+ return useAgUiRuntime({
94
+ agent,
95
+ adapters: {
96
+ threadList: {
97
+ threadId,
98
+ onSwitchToThread: async (id) => {
99
+ agent.threadId = id;
100
+ setThreadId(id);
101
+ return { messages: [question(id)], unstable_resume: true };
102
+ },
103
+ onSwitchToNewThread: () => {
104
+ agent.threadId = "thread-new";
105
+ setThreadId("thread-new");
106
+ },
107
+ },
108
+ },
109
+ });
110
+ });
111
+ return { agent, abortRun, detachActiveRun, ...view };
112
+ }
113
+
114
+ const lastText = (messages: readonly ThreadMessage[]) => {
115
+ const part = messages.at(-1)?.content[0];
116
+ return part?.type === "text" ? part.text : undefined;
117
+ };
118
+
119
+ describe("useAgUiRuntime with an agent that implements connect()", () => {
120
+ it("leaves a running thread by detaching, then rejoins the next thread's run", async () => {
121
+ const { agent, abortRun, detachActiveRun, result } = renderDurable();
122
+
123
+ await act(async () => {
124
+ await result.current.threads.switchToThread("thread-a");
125
+ });
126
+ await waitFor(() =>
127
+ expect(lastText(result.current.thread.getState().messages)).toBe(
128
+ "streaming thread-a",
129
+ ),
130
+ );
131
+
132
+ await act(async () => {
133
+ await result.current.threads.switchToThread("thread-b");
134
+ });
135
+
136
+ await waitFor(() =>
137
+ expect(lastText(result.current.thread.getState().messages)).toBe(
138
+ "streaming thread-b",
139
+ ),
140
+ );
141
+ expect(agent.connected).toEqual(["thread-a", "thread-b"]);
142
+ expect(
143
+ result.current.thread.getState().messages.map((message) => message.id),
144
+ ).toEqual(["question-thread-b", "answer-thread-b"]);
145
+ expect(detachActiveRun).toHaveBeenCalled();
146
+ expect(abortRun).not.toHaveBeenCalled();
147
+
148
+ await act(async () => {
149
+ await result.current.threads.switchToNewThread();
150
+ });
151
+ expect(abortRun).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it("aborts the run when the user cancels", async () => {
155
+ const { abortRun, result } = renderDurable();
156
+ await act(async () => {
157
+ await result.current.threads.switchToThread("thread-a");
158
+ });
159
+ await waitFor(() =>
160
+ expect(result.current.thread.getState().isRunning).toBe(true),
161
+ );
162
+
163
+ act(() => {
164
+ result.current.thread.cancelRun();
165
+ });
166
+
167
+ expect(abortRun).toHaveBeenCalledTimes(1);
168
+ });
169
+ });
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { act, cleanup, renderHook } from "@testing-library/react";
4
- import type { HttpAgent } from "@ag-ui/client";
4
+ import { HttpAgent } from "@ag-ui/client";
5
5
  import { afterEach, expect, it, vi } from "vitest";
6
6
  import { useAgUiRuntime } from "./useAgUiRuntime";
7
7
 
@@ -88,3 +88,48 @@ it("passes joinStrategy to the active run", async () => {
88
88
  { type: "text", text: "B" },
89
89
  ]);
90
90
  });
91
+
92
+ it("batches text deltas emitted from one response before re-rendering the host", async () => {
93
+ const deltas = Array.from({ length: 20 }, (_, index) => String(index));
94
+ const events = [
95
+ { type: "RUN_STARTED", threadId: "main", runId: "run" },
96
+ { type: "TEXT_MESSAGE_START", messageId: "answer" },
97
+ ...deltas.map((delta) => ({
98
+ type: "TEXT_MESSAGE_CONTENT",
99
+ messageId: "answer",
100
+ delta,
101
+ })),
102
+ { type: "TEXT_MESSAGE_END", messageId: "answer" },
103
+ { type: "RUN_FINISHED", threadId: "main", runId: "run" },
104
+ ];
105
+ const agent = new HttpAgent({
106
+ url: "http://agent.test/agent",
107
+ fetch: async () =>
108
+ new Response(
109
+ events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
110
+ { headers: { "content-type": "text/event-stream" } },
111
+ ),
112
+ });
113
+ let renders = 0;
114
+ const { result } = renderHook(() => {
115
+ renders++;
116
+ return useAgUiRuntime({ agent });
117
+ });
118
+
119
+ const baseline = renders;
120
+ const pending = result.current.thread.append({
121
+ role: "user",
122
+ content: [{ type: "text", text: "go" }],
123
+ });
124
+ await pending;
125
+ await new Promise((resolve) => setTimeout(resolve, 0));
126
+
127
+ const assistant = result.current.thread
128
+ .getState()
129
+ .messages.find((message) => message.role === "assistant");
130
+ expect(assistant).toMatchObject({
131
+ content: [{ type: "text", text: deltas.join("") }],
132
+ status: { type: "complete" },
133
+ });
134
+ expect(renders - baseline).toBeLessThanOrEqual(6);
135
+ });
@@ -76,6 +76,8 @@ export function useAgUiRuntime(
76
76
  logger,
77
77
  showThinking: options.showThinking ?? true,
78
78
  joinStrategy: options.joinStrategy,
79
+ joinAdjacentAssistantMessages:
80
+ options.joinAdjacentAssistantMessages ?? false,
79
81
  getRunContext: options.getRunContext,
80
82
  resumeTranscript: options.resumeTranscript,
81
83
  autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
@@ -98,6 +100,8 @@ export function useAgUiRuntime(
98
100
  logger,
99
101
  showThinking: options.showThinking ?? true,
100
102
  joinStrategy: options.joinStrategy,
103
+ joinAdjacentAssistantMessages:
104
+ options.joinAdjacentAssistantMessages ?? false,
101
105
  getRunContext: options.getRunContext,
102
106
  resumeTranscript: options.resumeTranscript,
103
107
  autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
@@ -223,12 +227,14 @@ export function useAgUiRuntime(
223
227
  const abandonPreviousThread = () => {
224
228
  queueRef.current?.notifyCancelled();
225
229
  if (core.isRunning()) {
226
- void core.cancel().catch((error: unknown) => {
230
+ try {
231
+ core.leave();
232
+ } catch (error) {
227
233
  logger.error?.(
228
234
  "[agui] cancelling the run on thread switch failed",
229
235
  error,
230
236
  );
231
- });
237
+ }
232
238
  }
233
239
  queueRef.current?.clear();
234
240
  };