@assistant-ui/react-ag-ui 0.0.64 → 0.0.66

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 (35) hide show
  1. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +19 -3
  2. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  3. package/dist/runtime/AgUiThreadRuntimeCore.js +193 -39
  4. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  5. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  6. package/dist/runtime/adapter/conversions.js +19 -29
  7. package/dist/runtime/adapter/conversions.js.map +1 -1
  8. package/dist/runtime/adapter/run-aggregator.d.ts +4 -16
  9. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.js +100 -23
  11. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  12. package/dist/runtime/event-parser.d.ts.map +1 -1
  13. package/dist/runtime/event-parser.js +16 -8
  14. package/dist/runtime/event-parser.js.map +1 -1
  15. package/dist/runtime/types.d.ts +30 -5
  16. package/dist/runtime/types.d.ts.map +1 -1
  17. package/dist/runtime/types.js +8 -0
  18. package/dist/runtime/types.js.map +1 -0
  19. package/dist/useAgUiRuntime.d.ts.map +1 -1
  20. package/dist/useAgUiRuntime.js +80 -33
  21. package/dist/useAgUiRuntime.js.map +1 -1
  22. package/package.json +7 -6
  23. package/src/runtime/AgUiThreadRuntimeCore.test.ts +380 -6
  24. package/src/runtime/AgUiThreadRuntimeCore.ts +314 -70
  25. package/src/runtime/adapter/conversions.content.test.ts +41 -0
  26. package/src/runtime/adapter/conversions.nested-tool-calls.test.ts +107 -0
  27. package/src/runtime/adapter/conversions.ts +37 -64
  28. package/src/runtime/adapter/run-aggregator.reasoning.test.ts +143 -0
  29. package/src/runtime/adapter/run-aggregator.ts +163 -40
  30. package/src/runtime/event-parser.ts +18 -4
  31. package/src/runtime/types.ts +43 -11
  32. package/src/useAgUiRuntime.external-store.test.tsx +51 -0
  33. package/src/useAgUiRuntime.queue.test.tsx +63 -2
  34. package/src/useAgUiRuntime.thread-switch.test.tsx +587 -3
  35. package/src/useAgUiRuntime.ts +106 -44
@@ -40,6 +40,47 @@ const appendMessage = (): AppendMessage => ({
40
40
  metadata: { custom: {} },
41
41
  });
42
42
 
43
+ describe("toAgUiMessages data URLs", () => {
44
+ it.each(["application/pdf", ""])(
45
+ "unwraps a media-less data URL while retaining the adapter's %j MIME fallback",
46
+ (mimeType) => {
47
+ expect(
48
+ contentOf(
49
+ userMessage([
50
+ { type: "file", data: "data:;base64,SGVsbG8=", mimeType },
51
+ ]),
52
+ ),
53
+ ).toEqual([
54
+ {
55
+ type: "document",
56
+ source: {
57
+ type: "data",
58
+ value: "SGVsbG8=",
59
+ mimeType: mimeType || "application/octet-stream",
60
+ },
61
+ },
62
+ ]);
63
+ },
64
+ );
65
+
66
+ it("sniffs a media-less image data URL", () => {
67
+ expect(
68
+ contentOf(
69
+ userMessage([{ type: "image", image: "data:;base64,iVBORw0KGgo=" }]),
70
+ ),
71
+ ).toEqual([
72
+ {
73
+ type: "image",
74
+ source: {
75
+ type: "data",
76
+ value: "iVBORw0KGgo=",
77
+ mimeType: "image/png",
78
+ },
79
+ },
80
+ ]);
81
+ });
82
+ });
83
+
43
84
  describe("toAgUiMessages content metadata", () => {
44
85
  it("emits AgUiMessage values assignable to AG-UI Message", () => {
45
86
  expectTypeOf<AgUiMessage>().toExtend<AgUiWireMessage>();
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ThreadMessageLike } from "@assistant-ui/core";
3
+ import { toAgUiMessages } from "./conversions";
4
+
5
+ type Message = ThreadMessageLike;
6
+ type ToolCall = Extract<
7
+ Exclude<Message["content"], string>[number],
8
+ { type: "tool-call" }
9
+ >;
10
+
11
+ const toolCall = (
12
+ toolCallId: string,
13
+ messages?: readonly Message[],
14
+ ): ToolCall =>
15
+ ({
16
+ type: "tool-call",
17
+ toolCallId,
18
+ toolName: "test",
19
+ args: {},
20
+ ...(messages ? { messages } : {}),
21
+ }) as ToolCall;
22
+
23
+ const assistant = (id: string, content: readonly ToolCall[]): Message =>
24
+ ({ id, role: "assistant", content }) as Message;
25
+
26
+ const deepToolCall = (depth: number) => {
27
+ let part = toolCall(String(depth - 1));
28
+ for (let index = depth - 2; index >= 0; index--) {
29
+ part = toolCall(String(index), [assistant(`nested-${index}`, [part])]);
30
+ }
31
+ return part;
32
+ };
33
+
34
+ describe("toAgUiMessages nested tool calls", () => {
35
+ it("preserves document order", () => {
36
+ const [converted] = toAgUiMessages([
37
+ assistant("root", [
38
+ toolCall("root-call", [
39
+ assistant("nested", [
40
+ toolCall("first", [assistant("deep", [toolCall("first-child")])]),
41
+ toolCall("second"),
42
+ ]),
43
+ ]),
44
+ ]),
45
+ ]);
46
+
47
+ expect(converted).toMatchObject({ role: "assistant" });
48
+ if (converted?.role !== "assistant") throw new Error("expected assistant");
49
+ expect(converted.toolCalls?.map(({ id }) => id)).toEqual([
50
+ "root-call",
51
+ "first",
52
+ "first-child",
53
+ "second",
54
+ ]);
55
+ });
56
+
57
+ it("prunes nested tool calls below filtered a2ui parts", () => {
58
+ const [converted] = toAgUiMessages([
59
+ assistant("root", [
60
+ toolCall("root-call", [
61
+ assistant("nested", [
62
+ toolCall("a2ui:surface", [
63
+ assistant("hidden", [toolCall("hidden")]),
64
+ ]),
65
+ toolCall("visible"),
66
+ ]),
67
+ ]),
68
+ ]),
69
+ ]);
70
+
71
+ expect(converted).toMatchObject({ role: "assistant" });
72
+ if (converted?.role !== "assistant") throw new Error("expected assistant");
73
+ expect(converted.toolCalls?.map(({ id }) => id)).toEqual([
74
+ "root-call",
75
+ "visible",
76
+ ]);
77
+ });
78
+
79
+ it("converts deeply nested tool calls without overflowing the stack", () => {
80
+ const depth = 10_000;
81
+
82
+ const [converted] = toAgUiMessages([
83
+ assistant("root", [deepToolCall(depth)]),
84
+ ]);
85
+
86
+ expect(converted).toMatchObject({ role: "assistant" });
87
+ if (converted?.role !== "assistant") throw new Error("expected assistant");
88
+ expect(converted.toolCalls).toHaveLength(depth);
89
+ expect(converted.toolCalls?.at(-1)?.id).toBe(String(depth - 1));
90
+ });
91
+
92
+ it("terminates cyclic nested tool-call messages without throwing", () => {
93
+ const messages: Message[] = [];
94
+ messages.push(assistant("nested", [toolCall("nested-call", messages)]));
95
+
96
+ const [converted] = toAgUiMessages([
97
+ assistant("root", [toolCall("root-call", messages)]),
98
+ ]);
99
+
100
+ expect(converted).toMatchObject({ role: "assistant" });
101
+ if (converted?.role !== "assistant") throw new Error("expected assistant");
102
+ expect(converted.toolCalls?.map(({ id }) => id)).toEqual([
103
+ "root-call",
104
+ "nested-call",
105
+ ]);
106
+ });
107
+ });
@@ -6,14 +6,13 @@ import type {
6
6
  ThreadMessageLike as CoreThreadMessageLike,
7
7
  PartProviderMetadata,
8
8
  ReasoningMessagePart,
9
- ThreadMessage,
10
- ToolCallMessagePartMcpMetadata,
11
- ToolModelContentPart,
12
9
  } from "@assistant-ui/core";
13
10
  import {
14
11
  getAutoStatus,
15
12
  parseDataUrl,
16
13
  resolveFilePartSource,
14
+ resolveImageMediaType,
15
+ walkToolCallTree,
17
16
  } from "@assistant-ui/core/internal";
18
17
  import { type Tool, toToolsJSONSchema } from "assistant-stream";
19
18
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
@@ -23,14 +22,13 @@ import {
23
22
  MCP_APPS_ACTIVITY_TYPE,
24
23
  type AgUiCustomMetadata,
25
24
  type AgUiOpaqueReasoning,
26
- } from "./run-aggregator";
25
+ } from "../types";
27
26
  import {
28
27
  applyA2uiOperations,
29
- convertSurfaceToUISpec,
30
- surfaceToOperations,
28
+ surfaceToPresentToolCall,
31
29
  type A2uiState,
32
30
  type A2uiSurfaceState,
33
- } from "@assistant-ui/react-generative-ui/a2ui";
31
+ } from "@assistant-ui/generative-ui/a2ui";
34
32
  import type { AgUiInterrupt } from "../types";
35
33
  import { projectAgUiToolApprovals } from "./tool-approval";
36
34
  import {
@@ -99,29 +97,18 @@ export type AgUiMessage =
99
97
  error?: string;
100
98
  };
101
99
 
102
- type ToolCallPart = {
103
- type: "tool-call";
104
- toolCallId?: string;
105
- toolName: string;
106
- argsText?: string;
107
- args?: ReadonlyJSONObject;
100
+ type CoreToolCallPart = Extract<
101
+ Exclude<CoreThreadMessageLike["content"], string>[number],
102
+ { type: "tool-call" }
103
+ >;
104
+
105
+ type ToolCallPart = Omit<CoreToolCallPart, "result" | "isError"> & {
108
106
  result?: unknown;
109
- artifact?: unknown;
110
- isError?: boolean;
111
- modelContent?: readonly ToolModelContentPart[];
107
+ isError?: boolean | undefined;
108
+ /** @deprecated Experimental since 2026-03-08. Not scheduled for removal; the API may change in any release. */
112
109
  unstable_toolMessageId?: string;
113
- mcp?: ToolCallMessagePartMcpMetadata;
114
- messages?: readonly ThreadMessage[];
115
- approval?: CoreToolCallPartApproval;
116
110
  };
117
111
 
118
- type CoreToolCallPartApproval = NonNullable<
119
- Extract<
120
- Exclude<CoreThreadMessageLike["content"], string>[number],
121
- { type: "tool-call" }
122
- >["approval"]
123
- >;
124
-
125
112
  const isObject = (value: unknown): value is Record<string, unknown> =>
126
113
  typeof value === "object" && value !== null;
127
114
 
@@ -215,6 +202,9 @@ function normalizeToolCall(part: ToolCallPart): {
215
202
  };
216
203
  }
217
204
 
205
+ const isExportableNestedToolCall = (part: { readonly toolCallId?: unknown }) =>
206
+ typeof part.toolCallId === "string" && !part.toolCallId.startsWith("a2ui:");
207
+
218
208
  function extractText(content: unknown): string {
219
209
  if (typeof content === "string") return content;
220
210
  if (!Array.isArray(content)) return "";
@@ -287,9 +277,16 @@ function toInputContent(
287
277
  const image = getString(part, "image");
288
278
  if (image === undefined) return null;
289
279
  const metadata = buildInputMetadata(part, getString(part, "filename"));
280
+ const source = buildInputSource(image, fallbackMimeType);
290
281
  return {
291
282
  type: "image",
292
- source: buildInputSource(image, fallbackMimeType),
283
+ source:
284
+ source.type === "data"
285
+ ? {
286
+ ...source,
287
+ mimeType: resolveImageMediaType(image, fallbackMimeType),
288
+ }
289
+ : source,
293
290
  ...(metadata && { metadata }),
294
291
  };
295
292
  }
@@ -513,7 +510,6 @@ function toToolCallPart(value: unknown): ToolCallPart | null {
513
510
  : isObject(value.args) && !Array.isArray(value.args)
514
511
  ? (value.args as ReadonlyJSONObject)
515
512
  : undefined;
516
-
517
513
  const part: ToolCallPart = {
518
514
  type: "tool-call",
519
515
  ...(toolCallId !== undefined ? { toolCallId } : {}),
@@ -704,27 +700,17 @@ function toUserOrSystemSnapshotMessage(
704
700
  };
705
701
  }
706
702
 
707
- // Rebuilds the a2ui:<surfaceId> "present" tool-call parts for one owning
708
- // assistant message from a bucket of rebuilt surface state, mirroring the
709
- // live RunAggregator.synthesizeA2uiToolCalls shape. Non-a2ui parts (text,
710
- // other tool calls) are preserved; existing a2ui parts are replaced wholesale
711
- // so create/update/delete within the bucket all converge on the rebuilt set.
712
703
  function attachA2uiSurfaces(
713
704
  message: CoreThreadMessageLike,
714
705
  state: A2uiState,
715
706
  ): CoreThreadMessageLike {
716
707
  const a2uiParts: ToolCallPart[] = [];
717
708
  for (const [surfaceId, surface] of state) {
718
- const { spec } = convertSurfaceToUISpec(surface);
719
- if (!spec) continue;
709
+ const { toolCall } = surfaceToPresentToolCall(surfaceId, surface);
710
+ if (!toolCall) continue;
720
711
  a2uiParts.push({
721
712
  type: "tool-call",
722
- toolCallId: `a2ui:${surfaceId}`,
723
- toolName: "present",
724
- args: spec as unknown as ReadonlyJSONObject,
725
- argsText: JSON.stringify(spec),
726
- result: {},
727
- artifact: { a2ui: surfaceToOperations(surface) },
713
+ ...toolCall,
728
714
  });
729
715
  }
730
716
 
@@ -1316,7 +1302,16 @@ function convertAssistantMessage(
1316
1302
  part: ToolCallPart;
1317
1303
  }[] = [];
1318
1304
  for (const { part } of toolCalls) {
1319
- collectNestedToolCalls(part, nestedToolCalls);
1305
+ for (const { part: nestedToolCall } of walkToolCallTree(
1306
+ part.messages ?? [],
1307
+ { shouldDescend: isExportableNestedToolCall },
1308
+ )) {
1309
+ if (!isExportableNestedToolCall(nestedToolCall)) continue;
1310
+ nestedToolCalls.push({
1311
+ ...normalizeToolCall(nestedToolCall),
1312
+ part: nestedToolCall,
1313
+ });
1314
+ }
1320
1315
  }
1321
1316
 
1322
1317
  converted.push({
@@ -1348,28 +1343,6 @@ function convertAssistantMessage(
1348
1343
  }
1349
1344
  }
1350
1345
 
1351
- function collectNestedToolCalls(
1352
- part: ToolCallPart,
1353
- out: { id: string; call: AgUiToolCall; part: ToolCallPart }[],
1354
- ): void {
1355
- for (const nested of part.messages ?? []) {
1356
- if (!isObject(nested) || nested.role !== "assistant") continue;
1357
- const nestedContent = Array.isArray(nested.content) ? nested.content : [];
1358
- for (const nestedPart of nestedContent) {
1359
- if (!isObject(nestedPart) || nestedPart.type !== "tool-call") continue;
1360
- const nestedToolCall = nestedPart as ToolCallPart;
1361
- if (
1362
- typeof nestedToolCall.toolCallId !== "string" ||
1363
- nestedToolCall.toolCallId.startsWith("a2ui:")
1364
- ) {
1365
- continue;
1366
- }
1367
- out.push({ ...normalizeToolCall(nestedToolCall), part: nestedToolCall });
1368
- collectNestedToolCalls(nestedToolCall, out);
1369
- }
1370
- }
1371
- }
1372
-
1373
1346
  function emitToolResult(
1374
1347
  toolCallId: string,
1375
1348
  part: ToolCallPart,
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ChatModelRunResult } from "@assistant-ui/core";
3
+ import { RunAggregator } from "./run-aggregator";
4
+ import type { AgUiCustomMetadata } from "../types";
5
+ import { toAgUiMessages } from "./conversions";
6
+
7
+ describe.each([true, false])(
8
+ "reasoning ownership (showThinking=%s)",
9
+ (showThinking) => {
10
+ it.each([
11
+ { parentText: "", nextReasoning: false },
12
+ { parentText: "", nextReasoning: true },
13
+ { parentText: "Looking it up.", nextReasoning: false },
14
+ { parentText: "Looking it up.", nextReasoning: true },
15
+ ])(
16
+ "keeps pre-tool reasoning on its parent ($parentText, nextReasoning=$nextReasoning)",
17
+ ({ parentText, nextReasoning }) => {
18
+ let latest: ChatModelRunResult = {};
19
+ let previous: ChatModelRunResult | undefined;
20
+ const aggregator = new RunAggregator({
21
+ showThinking,
22
+ logger: { debug: () => {}, error: () => {} },
23
+ emit: (update) => {
24
+ latest = update;
25
+ },
26
+ onTextMessageStart: () => {
27
+ previous = latest;
28
+ },
29
+ });
30
+ const reason = (messageId: string, delta: string) => {
31
+ aggregator.handle({ type: "REASONING_MESSAGE_START", messageId });
32
+ aggregator.handle({
33
+ type: "REASONING_MESSAGE_CONTENT",
34
+ messageId,
35
+ delta,
36
+ });
37
+ aggregator.handle({ type: "REASONING_MESSAGE_END", messageId });
38
+ aggregator.handle({
39
+ type: "REASONING_ENCRYPTED_VALUE",
40
+ subtype: "message",
41
+ entityId: messageId,
42
+ encryptedValue: `sig-${messageId}`,
43
+ });
44
+ };
45
+ const reasoningOf = (result: ChatModelRunResult | undefined) =>
46
+ result?.content?.flatMap((part) =>
47
+ part.type === "reasoning" ? [part.text] : [],
48
+ );
49
+ const opaqueOf = (result: ChatModelRunResult | undefined) =>
50
+ (result?.metadata?.custom?.agui as AgUiCustomMetadata | undefined)
51
+ ?.opaqueReasoning ?? [];
52
+
53
+ aggregator.handle({ type: "RUN_STARTED", runId: "run-1" });
54
+ aggregator.handle({
55
+ type: "TEXT_MESSAGE_START",
56
+ messageId: "assistant-1",
57
+ });
58
+ if (parentText)
59
+ aggregator.handle({
60
+ type: "TEXT_MESSAGE_CONTENT",
61
+ messageId: "assistant-1",
62
+ delta: parentText,
63
+ });
64
+ reason("r-1", "plan the call");
65
+ aggregator.handle({
66
+ type: "TOOL_CALL_START",
67
+ toolCallId: "call-1",
68
+ toolCallName: "lookup",
69
+ parentMessageId: "assistant-1",
70
+ });
71
+ aggregator.handle({ type: "TOOL_CALL_END", toolCallId: "call-1" });
72
+ aggregator.handle({
73
+ type: "TOOL_CALL_RESULT",
74
+ toolCallId: "call-1",
75
+ messageId: "tool-1",
76
+ content: "ok",
77
+ });
78
+ if (nextReasoning) reason("r-2", "write the answer");
79
+ aggregator.handle({
80
+ type: "TEXT_MESSAGE_START",
81
+ messageId: "assistant-2",
82
+ });
83
+ aggregator.handle({
84
+ type: "TEXT_MESSAGE_CONTENT",
85
+ messageId: "assistant-2",
86
+ delta: "Done.",
87
+ });
88
+ aggregator.handle({ type: "RUN_FINISHED", runId: "run-1" });
89
+
90
+ expect(
91
+ previous?.content?.filter((part) => part.type === "tool-call"),
92
+ ).toHaveLength(1);
93
+ expect(
94
+ latest.content?.filter((part) => part.type === "tool-call"),
95
+ ).toHaveLength(0);
96
+ if (showThinking) {
97
+ expect(reasoningOf(previous)).toEqual(["plan the call"]);
98
+ expect(reasoningOf(latest)).toEqual(
99
+ nextReasoning ? ["write the answer"] : [],
100
+ );
101
+ } else {
102
+ expect(opaqueOf(previous)).toEqual([
103
+ { id: "r-1", encryptedValue: "sig-r-1" },
104
+ ]);
105
+ expect(opaqueOf(latest)).toEqual(
106
+ nextReasoning ? [{ id: "r-2", encryptedValue: "sig-r-2" }] : [],
107
+ );
108
+ }
109
+ const exported = toAgUiMessages([
110
+ {
111
+ id: "assistant-1",
112
+ role: "assistant",
113
+ content: previous?.content ?? [],
114
+ metadata: previous?.metadata,
115
+ },
116
+ {
117
+ id: "assistant-2",
118
+ role: "assistant",
119
+ content: latest.content ?? [],
120
+ metadata: latest.metadata,
121
+ },
122
+ ]);
123
+ expect(exported.map((message) => message.id)).toEqual([
124
+ "r-1",
125
+ "assistant-1",
126
+ "tool-1",
127
+ ...(nextReasoning ? ["r-2"] : []),
128
+ "assistant-2",
129
+ ]);
130
+ expect(
131
+ exported.flatMap((message) =>
132
+ message.role === "reasoning"
133
+ ? [[message.id, message.encryptedValue]]
134
+ : [],
135
+ ),
136
+ ).toEqual([
137
+ ["r-1", "sig-r-1"],
138
+ ...(nextReasoning ? [["r-2", "sig-r-2"]] : []),
139
+ ]);
140
+ },
141
+ );
142
+ },
143
+ );