@assistant-ui/react-ag-ui 0.0.35 → 0.0.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-ag-ui",
3
- "version": "0.0.35",
3
+ "version": "0.0.38",
4
4
  "description": "AG-UI protocol adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "ag-ui",
@@ -29,9 +29,9 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@ag-ui/client": "^0.0.54",
33
- "@assistant-ui/core": "^0.2.10",
34
- "assistant-stream": "^0.3.20",
32
+ "@ag-ui/client": "^0.0.55",
33
+ "@assistant-ui/core": "^0.2.14",
34
+ "assistant-stream": "^0.3.21",
35
35
  "fast-json-patch": "^3.1.1"
36
36
  },
37
37
  "peerDependencies": {
@@ -44,11 +44,11 @@
44
44
  }
45
45
  },
46
46
  "devDependencies": {
47
- "@types/react": "^19.2.15",
48
- "react": "^19.2.6",
49
- "vitest": "^4.1.7",
47
+ "@types/react": "^19.2.17",
48
+ "react": "^19.2.7",
49
+ "vitest": "^4.1.8",
50
50
  "zod": "^4.4.3",
51
- "@assistant-ui/x-buildutils": "0.0.11"
51
+ "@assistant-ui/x-buildutils": "0.0.12"
52
52
  },
53
53
  "publishConfig": {
54
54
  "access": "public",
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { useAgUiRuntime } from "./useAgUiRuntime";
2
2
  export type { AgUiAssistantRuntime } from "./useAgUiRuntime";
3
+ export { fromAgUiMessages } from "./runtime/adapter/conversions";
4
+ export type { FromAgUiMessagesOptions } from "./runtime/adapter/conversions";
3
5
  export type {
4
6
  AgUiInterrupt,
5
7
  AgUiInterruptReason,
@@ -722,6 +722,7 @@ export class AgUiThreadRuntimeCore {
722
722
  unstable_annotations: [],
723
723
  unstable_data: [],
724
724
  steps: [],
725
+ isOptimistic: true,
725
726
  custom: {},
726
727
  },
727
728
  };
@@ -742,9 +743,11 @@ export class AgUiThreadRuntimeCore {
742
743
  );
743
744
  this.messages = this.messages.filter((m) => m.id !== oldId);
744
745
  } else {
745
- this.messages = this.messages.map((m) =>
746
- m.id === oldId ? { ...m, id: newId } : m,
747
- );
746
+ this.messages = this.messages.map((m) => {
747
+ if (m.id !== oldId) return m;
748
+ const { isOptimistic: _, ...metadata } = m.metadata;
749
+ return { ...m, id: newId, metadata } as ThreadMessage;
750
+ });
748
751
  }
749
752
 
750
753
  const pendingParent = this.assistantHistoryParents.get(oldId);
@@ -869,6 +872,7 @@ export class AgUiThreadRuntimeCore {
869
872
  unstable_annotations: annotations,
870
873
  unstable_data: data,
871
874
  steps,
875
+ ...(current.isOptimistic ? { isOptimistic: true } : {}),
872
876
  ...(incoming.timing ? { timing: incoming.timing } : {}),
873
877
  custom: incoming.custom
874
878
  ? { ...current.custom, ...incoming.custom }
@@ -911,16 +915,14 @@ export class AgUiThreadRuntimeCore {
911
915
 
912
916
  private importMessagesSnapshot(rawMessages: readonly unknown[]) {
913
917
  try {
914
- const normalized = fromAgUiMessages(rawMessages);
918
+ const normalized = fromAgUiMessages(rawMessages, {
919
+ showThinking: this.showThinking,
920
+ });
915
921
  const converted: ThreadMessage[] = [];
916
922
  for (const message of normalized) {
917
923
  try {
918
924
  converted.push(
919
- fromThreadMessageLike(
920
- message as any,
921
- generateId(),
922
- FALLBACK_USER_STATUS,
923
- ),
925
+ fromThreadMessageLike(message, generateId(), FALLBACK_USER_STATUS),
924
926
  );
925
927
  } catch (error) {
926
928
  this.logger.error?.(
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import type { InputContent } from "@ag-ui/client";
4
+ import type { ThreadMessageLike as CoreThreadMessageLike } from "@assistant-ui/core";
4
5
  import { type Tool, toToolsJSONSchema } from "assistant-stream";
6
+ import type { ReadonlyJSONObject } from "assistant-stream/utils";
5
7
 
6
8
  export type { InputContent };
7
9
 
@@ -46,9 +48,9 @@ export type AgUiMessage =
46
48
  type ToolCallPart = {
47
49
  type: "tool-call";
48
50
  toolCallId?: string;
49
- toolName?: string;
51
+ toolName: string;
50
52
  argsText?: string;
51
- args?: Record<string, unknown>;
53
+ args?: ReadonlyJSONObject;
52
54
  result?: unknown;
53
55
  isError?: boolean;
54
56
  unstable_toolMessageId?: string;
@@ -127,6 +129,40 @@ function parseDataUrl(
127
129
 
128
130
  const httpUrlPattern = /^https?:\/\//i;
129
131
 
132
+ type InputContentSource =
133
+ | { type: "data"; value: string; mimeType: string }
134
+ | { type: "url"; value: string; mimeType?: string };
135
+
136
+ type MediaInputType = "image" | "audio" | "video" | "document";
137
+
138
+ function mediaTypeForMime(mimeType: string | undefined): MediaInputType {
139
+ if (mimeType?.startsWith("image/")) return "image";
140
+ if (mimeType?.startsWith("audio/")) return "audio";
141
+ if (mimeType?.startsWith("video/")) return "video";
142
+ return "document";
143
+ }
144
+
145
+ // Build an AG-UI multimodal source from a data URL, raw base64 payload, or an
146
+ // http(s) URL. A `url` source may omit the mime type; a `data` source always
147
+ // resolves one (falling back to application/octet-stream).
148
+ function buildInputSource(
149
+ value: string,
150
+ declaredMimeType: string | undefined,
151
+ ): InputContentSource {
152
+ if (httpUrlPattern.test(value)) {
153
+ return declaredMimeType !== undefined
154
+ ? { type: "url", value, mimeType: declaredMimeType }
155
+ : { type: "url", value };
156
+ }
157
+ const parsed = parseDataUrl(value);
158
+ return {
159
+ type: "data",
160
+ value: parsed?.data ?? value,
161
+ mimeType:
162
+ parsed?.mimeType ?? declaredMimeType ?? "application/octet-stream",
163
+ };
164
+ }
165
+
130
166
  function toInputContent(
131
167
  part: unknown,
132
168
  fallbackMimeType: string | undefined,
@@ -143,57 +179,140 @@ function toInputContent(
143
179
  if (type === "image") {
144
180
  const image = getString(part, "image");
145
181
  if (image === undefined) return null;
146
- const parsed = parseDataUrl(image);
147
- if (parsed) {
148
- return {
149
- type: "image",
150
- source: {
151
- type: "data",
152
- value: parsed.data,
153
- mimeType: parsed.mimeType,
154
- },
155
- };
156
- }
157
- return {
158
- type: "image",
159
- source: {
160
- type: "url",
161
- value: image,
162
- ...(fallbackMimeType !== undefined
163
- ? { mimeType: fallbackMimeType }
164
- : {}),
165
- },
166
- };
182
+ return { type: "image", source: buildInputSource(image, fallbackMimeType) };
167
183
  }
168
184
 
169
185
  if (type === "file") {
170
186
  const data = getString(part, "data");
171
187
  if (data === undefined) return null;
172
- const partMimeType = getString(part, "mimeType");
188
+ const declaredMimeType = getString(part, "mimeType") || fallbackMimeType;
173
189
  const filename = getString(part, "filename");
174
- const mimeType =
175
- partMimeType || fallbackMimeType || "application/octet-stream";
176
-
177
- if (httpUrlPattern.test(data)) {
178
- return {
179
- type: "binary",
180
- mimeType,
181
- url: data,
182
- ...(filename !== undefined ? { filename } : {}),
183
- };
190
+ const source = buildInputSource(data, declaredMimeType);
191
+ const metadata = filename !== undefined ? { filename } : undefined;
192
+ switch (mediaTypeForMime(source.mimeType)) {
193
+ case "image":
194
+ return { type: "image", source, ...(metadata && { metadata }) };
195
+ case "audio":
196
+ return { type: "audio", source, ...(metadata && { metadata }) };
197
+ case "video":
198
+ return { type: "video", source, ...(metadata && { metadata }) };
199
+ default:
200
+ return { type: "document", source, ...(metadata && { metadata }) };
184
201
  }
185
- const parsed = parseDataUrl(data);
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ type SnapshotAttachment = NonNullable<
208
+ CoreThreadMessageLike["attachments"]
209
+ >[number];
210
+
211
+ const mediaInputTypes = new Set(["image", "audio", "video", "document"]);
212
+
213
+ // Inverse of buildInputSource.
214
+ function inputSourceToString(
215
+ value: unknown,
216
+ ): { value: string; mimeType?: string } | null {
217
+ if (!isObject(value)) return null;
218
+ const sourceValue = getString(value, "value");
219
+ if (sourceValue === undefined) return null;
220
+ const mimeType = getString(value, "mimeType");
221
+ const type = getString(value, "type");
222
+ if (type === "url") {
223
+ return { value: sourceValue, ...(mimeType !== undefined && { mimeType }) };
224
+ }
225
+ if (type === "data") {
226
+ const resolvedMimeType = mimeType ?? "application/octet-stream";
186
227
  return {
187
- type: "binary",
188
- mimeType: parsed?.mimeType ?? mimeType,
189
- data: parsed?.data ?? data,
190
- ...(filename !== undefined ? { filename } : {}),
228
+ value: `data:${resolvedMimeType};base64,${sourceValue}`,
229
+ mimeType: resolvedMimeType,
191
230
  };
192
231
  }
193
-
194
232
  return null;
195
233
  }
196
234
 
235
+ // Mirrors @ag-ui/client's BackwardCompatibility_0_0_47 middleware.
236
+ function upgradeBinaryInputPart(
237
+ part: Record<string, unknown>,
238
+ ): Record<string, unknown> | null {
239
+ const mimeType = getString(part, "mimeType");
240
+ if (mimeType === undefined) return null;
241
+ const data = getString(part, "data");
242
+ const url = getString(part, "url");
243
+ const source = data
244
+ ? { type: "data", value: data, mimeType }
245
+ : url
246
+ ? { type: "url", value: url, mimeType }
247
+ : null;
248
+ if (!source) return null;
249
+ const filename = getString(part, "filename");
250
+ return {
251
+ type: mediaTypeForMime(mimeType),
252
+ source,
253
+ ...(filename && { metadata: { filename } }),
254
+ };
255
+ }
256
+
257
+ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
258
+ if (!Array.isArray(content)) return [];
259
+
260
+ const attachments: SnapshotAttachment[] = [];
261
+ for (const rawPart of content) {
262
+ if (!isObject(rawPart)) continue;
263
+ const part =
264
+ getString(rawPart, "type") === "binary"
265
+ ? upgradeBinaryInputPart(rawPart)
266
+ : rawPart;
267
+ if (!part) continue;
268
+ const type = getString(part, "type");
269
+ if (type === undefined || !mediaInputTypes.has(type)) continue;
270
+ const source = inputSourceToString(part.source);
271
+ if (!source) continue;
272
+
273
+ const filename = isObject(part.metadata)
274
+ ? getString(part.metadata, "filename")
275
+ : undefined;
276
+ const id = attachments.length.toString();
277
+
278
+ if (type === "image") {
279
+ attachments.push({
280
+ id,
281
+ type: "image",
282
+ name: filename ?? "image",
283
+ ...(source.mimeType !== undefined && { contentType: source.mimeType }),
284
+ status: { type: "complete" },
285
+ content: [
286
+ {
287
+ type: "image",
288
+ image: source.value,
289
+ ...(filename !== undefined && { filename }),
290
+ },
291
+ ],
292
+ });
293
+ continue;
294
+ }
295
+
296
+ const mimeType = source.mimeType ?? "application/octet-stream";
297
+ attachments.push({
298
+ id,
299
+ type: type === "document" ? "document" : "file",
300
+ name: filename ?? "file",
301
+ contentType: mimeType,
302
+ status: { type: "complete" },
303
+ content: [
304
+ {
305
+ type: "file",
306
+ data: source.value,
307
+ mimeType,
308
+ ...(filename !== undefined && { filename }),
309
+ },
310
+ ],
311
+ });
312
+ }
313
+ return attachments;
314
+ }
315
+
197
316
  function buildUserContent(message: ThreadMessageLike): string | InputContent[] {
198
317
  // File parts in message.content are intentionally skipped: the canonical
199
318
  // binary payload for files always flows through message.attachments.
@@ -260,9 +379,9 @@ function toToolCallPart(value: unknown): ToolCallPart | null {
260
379
  typeof argsText === "string" ? parseJSONText(argsText) : undefined;
261
380
  const args =
262
381
  isObject(parsedArgs) && !Array.isArray(parsedArgs)
263
- ? (parsedArgs as Record<string, unknown>)
382
+ ? (parsedArgs as ReadonlyJSONObject)
264
383
  : isObject(value.args) && !Array.isArray(value.args)
265
- ? (value.args as Record<string, unknown>)
384
+ ? (value.args as ReadonlyJSONObject)
266
385
  : undefined;
267
386
 
268
387
  const part: ToolCallPart = {
@@ -322,7 +441,7 @@ function extractAssistantToolCalls(
322
441
 
323
442
  function toAssistantSnapshotMessage(
324
443
  rawMessage: Record<string, unknown>,
325
- ): ThreadMessageLike {
444
+ ): CoreThreadMessageLike {
326
445
  const text = extractText(rawMessage.content);
327
446
  const toolCallParts = extractAssistantToolCalls(rawMessage);
328
447
  const assistantContent = [
@@ -341,20 +460,33 @@ function toAssistantSnapshotMessage(
341
460
  function toUserOrSystemSnapshotMessage(
342
461
  role: "user" | "system",
343
462
  rawMessage: Record<string, unknown>,
344
- ): ThreadMessageLike {
463
+ ): CoreThreadMessageLike {
345
464
  const messageName = getString(rawMessage, "name");
465
+ const attachments =
466
+ role === "user" ? toSnapshotAttachments(rawMessage.content) : [];
346
467
  return {
347
468
  id: getString(rawMessage, "id") ?? generateId(),
348
469
  role,
349
470
  content: extractText(rawMessage.content),
350
471
  ...(messageName !== undefined ? { name: messageName } : {}),
472
+ ...(attachments.length > 0 ? { attachments } : {}),
351
473
  };
352
474
  }
353
475
 
476
+ export type FromAgUiMessagesOptions = {
477
+ /**
478
+ * Whether to convert `reasoning` messages into visible reasoning parts.
479
+ * Matches the `showThinking` option of `useAgUiRuntime`. Defaults to `true`.
480
+ */
481
+ showThinking?: boolean;
482
+ };
483
+
354
484
  export function fromAgUiMessages(
355
485
  messages: readonly unknown[],
356
- ): ThreadMessageLike[] {
357
- const converted: ThreadMessageLike[] = [];
486
+ options?: FromAgUiMessagesOptions,
487
+ ): CoreThreadMessageLike[] {
488
+ const showThinking = options?.showThinking ?? true;
489
+ const converted: CoreThreadMessageLike[] = [];
358
490
 
359
491
  for (const rawMessage of messages) {
360
492
  if (!isObject(rawMessage)) continue;
@@ -454,6 +586,20 @@ export function fromAgUiMessages(
454
586
  continue;
455
587
  }
456
588
 
589
+ if (role === "reasoning") {
590
+ // Gate on showThinking so a cold reload matches the live run: the
591
+ // aggregator never stores reasoning parts when showThinking is false.
592
+ if (!showThinking) continue;
593
+ const text = extractText(rawMessage.content);
594
+ if (text.trim().length === 0) continue;
595
+ converted.push({
596
+ id: getString(rawMessage, "id") ?? generateId(),
597
+ role: "assistant",
598
+ content: [{ type: "reasoning", text }],
599
+ });
600
+ continue;
601
+ }
602
+
457
603
  if (role === "user" || role === "system") {
458
604
  converted.push(toUserOrSystemSnapshotMessage(role, rawMessage));
459
605
  }
@@ -478,6 +624,12 @@ function convertAssistantMessage(
478
624
  part,
479
625
  }));
480
626
 
627
+ // Drop assistant messages with no text or tool calls (e.g. an imported
628
+ // reasoning-only entry) so they are not re-sent as a blank assistant turn.
629
+ if (content.length === 0 && toolCalls.length === 0) {
630
+ return;
631
+ }
632
+
481
633
  const assistantMessage: AgUiMessage = {
482
634
  id: message.id,
483
635
  role: "assistant",
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
 
3
- import type {
4
- ChatModelRunResult,
5
- MessageTiming,
6
- ThreadAssistantMessagePart,
7
- ToolCallMessagePart,
3
+ import {
4
+ isMcpAppUri,
5
+ type ChatModelRunResult,
6
+ type MessageTiming,
7
+ type ThreadAssistantMessagePart,
8
+ type ToolCallMessagePart,
8
9
  } from "@assistant-ui/core";
9
10
  import type { AgUiEvent, AgUiInterrupt } from "../types";
10
11
  import type { Logger } from "../logger";
@@ -26,8 +27,11 @@ type ToolCallState = {
26
27
  isError: boolean | undefined;
27
28
  parentMessageId?: string;
28
29
  toolMessageId?: string;
30
+ mcpAppResourceUri?: string;
29
31
  };
30
32
 
33
+ const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
34
+
31
35
  export type RunAggregatorOptions = {
32
36
  showThinking: boolean;
33
37
  logger: Logger;
@@ -58,6 +62,7 @@ export class RunAggregator {
58
62
  private activeReasoningKey: string | undefined;
59
63
  private reasoningPartCounter = 0;
60
64
  private readonly toolCalls = new Map<string, ToolCallState>();
65
+ private lastResolvedToolCallId: string | undefined;
61
66
  private readonly partOrder: (
62
67
  | { kind: "text"; key: string }
63
68
  | { kind: "reasoning"; key: string }
@@ -85,6 +90,7 @@ export class RunAggregator {
85
90
  this.activeReasoningKey = undefined;
86
91
  this.reasoningPartCounter = 0;
87
92
  this.toolCalls.clear();
93
+ this.lastResolvedToolCallId = undefined;
88
94
  this.partOrder.length = 0;
89
95
  this.textPartCounter = 0;
90
96
  this.activeTextMessageId = undefined;
@@ -224,6 +230,21 @@ export class RunAggregator {
224
230
  this.emit();
225
231
  break;
226
232
  }
233
+ case "ACTIVITY_SNAPSHOT": {
234
+ if (event.activityType !== MCP_APPS_ACTIVITY_TYPE) break;
235
+ const id = this.lastResolvedToolCallId;
236
+ const entry = id ? this.toolCalls.get(id) : undefined;
237
+ const resourceUri = event.content.resourceUri;
238
+ if (
239
+ entry &&
240
+ typeof resourceUri === "string" &&
241
+ isMcpAppUri(resourceUri)
242
+ ) {
243
+ entry.mcpAppResourceUri = resourceUri;
244
+ this.emit();
245
+ }
246
+ break;
247
+ }
227
248
 
228
249
  default: {
229
250
  this.logger.debug?.("[agui] aggregator ignored event", event);
@@ -372,6 +393,7 @@ export class RunAggregator {
372
393
  if (toolMessageId) {
373
394
  entry.toolMessageId = toolMessageId;
374
395
  }
396
+ this.lastResolvedToolCallId = id;
375
397
  }
376
398
 
377
399
  private tryParseJSON(value: string): unknown {
@@ -415,6 +437,9 @@ export class RunAggregator {
415
437
  argsText: entry.argsText,
416
438
  ...(entry.result !== undefined ? { result: entry.result } : {}),
417
439
  ...(entry.isError !== undefined ? { isError: entry.isError } : {}),
440
+ ...(entry.mcpAppResourceUri
441
+ ? { mcp: { app: { resourceUri: entry.mcpAppResourceUri } } }
442
+ : {}),
418
443
  ...(entry.parentMessageId ? { parentId: entry.parentMessageId } : {}),
419
444
  ...(entry.toolMessageId
420
445
  ? { unstable_toolMessageId: entry.toolMessageId }
@@ -27,6 +27,7 @@ type Subscriber = {
27
27
  onToolCallEndEvent?: (payload: { event: unknown }) => void;
28
28
  onToolCallChunkEvent?: (payload: { event: unknown }) => void;
29
29
  onToolCallResultEvent?: (payload: { event: unknown }) => void;
30
+ onActivitySnapshotEvent?: (payload: { event: unknown }) => void;
30
31
  onStateSnapshotEvent?: (payload: { event: unknown }) => void;
31
32
  onStateDeltaEvent?: (payload: { event: unknown }) => void;
32
33
  onMessagesSnapshotEvent?: (payload: { event: unknown }) => void;
@@ -122,6 +123,8 @@ export const createAgUiSubscriber = (
122
123
  dispatchIfValid(event, "TOOL_CALL_CHUNK"),
123
124
  onToolCallResultEvent: ({ event }) =>
124
125
  dispatchIfValid(event, "TOOL_CALL_RESULT"),
126
+ onActivitySnapshotEvent: ({ event }) =>
127
+ dispatchIfValid(event, "ACTIVITY_SNAPSHOT"),
125
128
  onStateSnapshotEvent: ({ event }) =>
126
129
  dispatchIfValid(event, "STATE_SNAPSHOT"),
127
130
  onStateDeltaEvent: ({ event }) => dispatchIfValid(event, "STATE_DELTA"),
@@ -235,6 +235,15 @@ export const parseAgUiEvent = (
235
235
  ? (payload.messages as any[])
236
236
  : [],
237
237
  };
238
+ case "ACTIVITY_SNAPSHOT": {
239
+ const activityType = getString("activityType");
240
+ if (!activityType || !isPlainObject(payload.content)) return null;
241
+ return {
242
+ type: "ACTIVITY_SNAPSHOT" as const,
243
+ activityType,
244
+ content: payload.content,
245
+ };
246
+ }
238
247
  case "RAW":
239
248
  return withOptional(
240
249
  { type: "RAW" as const, event: payload.event },
@@ -121,6 +121,11 @@ export type AgUiEvent =
121
121
  content: string;
122
122
  role?: "tool";
123
123
  }
124
+ | {
125
+ type: "ACTIVITY_SNAPSHOT";
126
+ activityType: string;
127
+ content: Record<string, unknown>;
128
+ }
124
129
  | { type: "RAW"; event: any; source?: string }
125
130
  | { type: "CUSTOM"; name: string; value: any }
126
131
  | { type: "STATE_SNAPSHOT"; snapshot: any }