@assistant-ui/react-ag-ui 0.0.45 → 0.0.46

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.
@@ -1,6 +1,10 @@
1
1
  "use client";
2
2
 
3
- import { generateId, fromThreadMessageLike } from "@assistant-ui/core";
3
+ import {
4
+ generateId,
5
+ fromThreadMessageLike,
6
+ isMcpAppUri,
7
+ } from "@assistant-ui/core";
4
8
  import type {
5
9
  AddToolResultOptions,
6
10
  AppendMessage,
@@ -24,6 +28,8 @@ import type { ReadonlyJSONValue } from "assistant-stream/utils";
24
28
  import {
25
29
  AG_UI_METADATA_NAMESPACE,
26
30
  type AgUiCustomMetadata,
31
+ isPlainObject,
32
+ MCP_APPS_ACTIVITY_TYPE,
27
33
  RunAggregator,
28
34
  tryParseJSON,
29
35
  } from "./adapter/run-aggregator";
@@ -425,25 +431,11 @@ export class AgUiThreadRuntimeCore {
425
431
  );
426
432
  }
427
433
 
428
- const responsesById = new Map<string, AgUiResumeEntry>();
429
- for (const entry of responses) {
430
- if (!entry || typeof entry.interruptId !== "string") {
431
- throw new Error(
432
- "[agui] submitInterruptResponses: every entry must have an interruptId",
433
- );
434
- }
435
- if (entry.status !== "resolved" && entry.status !== "cancelled") {
436
- throw new Error(
437
- `[agui] submitInterruptResponses: invalid status "${entry.status}" for interrupt ${entry.interruptId}`,
438
- );
439
- }
440
- if (responsesById.has(entry.interruptId)) {
441
- throw new Error(
442
- `[agui] submitInterruptResponses: duplicate response for interrupt ${entry.interruptId}`,
443
- );
444
- }
445
- responsesById.set(entry.interruptId, entry);
446
- }
434
+ const responsesById = this.collectInterruptResponses(
435
+ "submitInterruptResponses",
436
+ pending.interrupts,
437
+ responses,
438
+ );
447
439
 
448
440
  const openIds = pending.interrupts.map((i) => i.id);
449
441
  const missing = openIds.filter((id) => !responsesById.has(id));
@@ -452,13 +444,6 @@ export class AgUiThreadRuntimeCore {
452
444
  `[agui] submitInterruptResponses: missing responses for open interrupts: ${missing.join(", ")}`,
453
445
  );
454
446
  }
455
- const known = new Set(openIds);
456
- const unknownIds = [...responsesById.keys()].filter((id) => !known.has(id));
457
- if (unknownIds.length > 0) {
458
- throw new Error(
459
- `[agui] submitInterruptResponses: unknown interrupt ids: ${unknownIds.join(", ")}`,
460
- );
461
- }
462
447
 
463
448
  const now = Date.now();
464
449
  for (const interrupt of pending.interrupts) {
@@ -538,35 +523,51 @@ export class AgUiThreadRuntimeCore {
538
523
  interrupts: readonly AgUiInterrupt[],
539
524
  responses: readonly AgUiResumeEntry[] | undefined,
540
525
  ): AgUiResumeEntry[] {
541
- const openIds = interrupts.map((interrupt) => interrupt.id);
542
- const known = new Set(openIds);
526
+ const responsesById = this.collectInterruptResponses(
527
+ "steerAway",
528
+ interrupts,
529
+ responses ?? [],
530
+ );
531
+ return interrupts.map(
532
+ (interrupt) =>
533
+ responsesById.get(interrupt.id) ?? {
534
+ interruptId: interrupt.id,
535
+ status: "cancelled",
536
+ },
537
+ );
538
+ }
539
+
540
+ private collectInterruptResponses(
541
+ method: "submitInterruptResponses" | "steerAway",
542
+ interrupts: readonly AgUiInterrupt[],
543
+ responses: readonly AgUiResumeEntry[],
544
+ ): Map<string, AgUiResumeEntry> {
545
+ const known = new Set(interrupts.map((interrupt) => interrupt.id));
543
546
  const responsesById = new Map<string, AgUiResumeEntry>();
544
- for (const entry of responses ?? []) {
547
+ for (const entry of responses) {
545
548
  if (!entry || typeof entry.interruptId !== "string") {
546
549
  throw new Error(
547
- "[agui] steerAway: every response must have an interruptId",
550
+ `[agui] ${method}: every entry must have an interruptId`,
548
551
  );
549
552
  }
550
553
  if (entry.status !== "resolved" && entry.status !== "cancelled") {
551
554
  throw new Error(
552
- `[agui] steerAway: invalid status "${entry.status}" for interrupt ${entry.interruptId}`,
555
+ `[agui] ${method}: invalid status "${entry.status}" for interrupt ${entry.interruptId}`,
553
556
  );
554
557
  }
555
558
  if (!known.has(entry.interruptId)) {
556
559
  throw new Error(
557
- `[agui] steerAway: unknown interrupt id ${entry.interruptId}`,
560
+ `[agui] ${method}: unknown interrupt id ${entry.interruptId}`,
558
561
  );
559
562
  }
560
563
  if (responsesById.has(entry.interruptId)) {
561
564
  throw new Error(
562
- `[agui] steerAway: duplicate response for interrupt ${entry.interruptId}`,
565
+ `[agui] ${method}: duplicate response for interrupt ${entry.interruptId}`,
563
566
  );
564
567
  }
565
568
  responsesById.set(entry.interruptId, entry);
566
569
  }
567
- return openIds.map(
568
- (id) => responsesById.get(id) ?? { interruptId: id, status: "cancelled" },
569
- );
570
+ return responsesById;
570
571
  }
571
572
 
572
573
  private toAppendMessage(message: CreateAppendMessage): AppendMessage {
@@ -1344,6 +1345,22 @@ export class AgUiThreadRuntimeCore {
1344
1345
  aggregator.handle(event);
1345
1346
  return;
1346
1347
  }
1348
+ case "ACTIVITY_SNAPSHOT": {
1349
+ const toolCallId = event.content["toolCallId"];
1350
+ if (
1351
+ event.activityType === MCP_APPS_ACTIVITY_TYPE &&
1352
+ typeof toolCallId === "string" &&
1353
+ !aggregator.hasToolCall(toolCallId)
1354
+ ) {
1355
+ const messageId = this.findMessageIdForToolCall(toolCallId);
1356
+ if (messageId !== undefined) {
1357
+ this.applyCrossRunActivitySnapshot(messageId, toolCallId, event);
1358
+ return;
1359
+ }
1360
+ }
1361
+ aggregator.handle(event);
1362
+ return;
1363
+ }
1347
1364
  default:
1348
1365
  aggregator.handle(event);
1349
1366
  }
@@ -1361,10 +1378,49 @@ export class AgUiThreadRuntimeCore {
1361
1378
  if (part.type !== "tool-call" || part.toolCallId !== event.toolCallId)
1362
1379
  return part;
1363
1380
  matchedToolCall = true;
1381
+ // An applied activity snapshot owns part.result; a later result only
1382
+ // fills what is missing, mirroring the aggregator's finishToolCall.
1383
+ // The aggregator's flag is set only when the snapshot carried a
1384
+ // result, so app presence alone is not enough: the current result
1385
+ // must be CallToolResult-shaped (a required content array).
1386
+ const snapshotResultApplied =
1387
+ part.mcp?.app !== undefined &&
1388
+ isPlainObject(part.result) &&
1389
+ Array.isArray((part.result as Record<string, unknown>)["content"]);
1390
+ if (snapshotResultApplied) {
1391
+ return {
1392
+ ...part,
1393
+ ...(part.modelContent === undefined && event.content
1394
+ ? {
1395
+ modelContent: [
1396
+ { type: "text" as const, text: event.content },
1397
+ ],
1398
+ }
1399
+ : {}),
1400
+ ...(part.isError === undefined
1401
+ ? typeof event.mcpResult?.isError === "boolean"
1402
+ ? { isError: event.mcpResult.isError }
1403
+ : event.role === "tool"
1404
+ ? { isError: false }
1405
+ : {}
1406
+ : {}),
1407
+ ...(event.messageId
1408
+ ? { unstable_toolMessageId: event.messageId }
1409
+ : {}),
1410
+ };
1411
+ }
1364
1412
  return {
1365
1413
  ...part,
1366
- result: tryParseJSON(event.content ?? "") as ReadonlyJSONValue,
1367
- ...(event.role === "tool" ? { isError: false } : {}),
1414
+ result: (event.mcpResult ??
1415
+ tryParseJSON(event.content ?? "")) as ReadonlyJSONValue,
1416
+ ...(event.mcpResult !== undefined
1417
+ ? { modelContent: [{ type: "text" as const, text: event.content }] }
1418
+ : {}),
1419
+ ...(typeof event.mcpResult?.isError === "boolean"
1420
+ ? { isError: event.mcpResult.isError }
1421
+ : event.role === "tool"
1422
+ ? { isError: false }
1423
+ : {}),
1368
1424
  ...(event.messageId
1369
1425
  ? { unstable_toolMessageId: event.messageId }
1370
1426
  : {}),
@@ -1381,6 +1437,68 @@ export class AgUiThreadRuntimeCore {
1381
1437
  this.maybeCompleteAfterToolResults(messageId);
1382
1438
  }
1383
1439
 
1440
+ private applyCrossRunActivitySnapshot(
1441
+ messageId: string,
1442
+ toolCallId: string,
1443
+ event: Extract<AgUiEvent, { type: "ACTIVITY_SNAPSHOT" }>,
1444
+ ): void {
1445
+ const resourceUri = event.content["resourceUri"];
1446
+ if (typeof resourceUri !== "string" || !isMcpAppUri(resourceUri)) return;
1447
+ const serverId = event.content["serverId"];
1448
+ const serverHash = event.content["serverHash"];
1449
+ const appServerId =
1450
+ typeof serverId === "string" && serverId.length > 0
1451
+ ? serverId
1452
+ : typeof serverHash === "string" && serverHash.length > 0
1453
+ ? serverHash
1454
+ : undefined;
1455
+ const result = event.content["result"];
1456
+ const updated = this.updateMessage(messageId, (message) => {
1457
+ if (message.role !== "assistant") return message;
1458
+ const assistant = message as ThreadAssistantMessage;
1459
+ let matchedToolCall = false;
1460
+ const content = assistant.content.map((part) => {
1461
+ if (part.type !== "tool-call" || part.toolCallId !== toolCallId)
1462
+ return part;
1463
+ matchedToolCall = true;
1464
+ return {
1465
+ ...part,
1466
+ mcp: {
1467
+ app: {
1468
+ resourceUri,
1469
+ ...(appServerId ? { serverId: appServerId } : {}),
1470
+ },
1471
+ },
1472
+ ...(isPlainObject(result)
1473
+ ? {
1474
+ ...(part.result !== undefined && part.modelContent === undefined
1475
+ ? {
1476
+ modelContent: [
1477
+ {
1478
+ type: "text" as const,
1479
+ text:
1480
+ typeof part.result === "string"
1481
+ ? part.result
1482
+ : JSON.stringify(part.result),
1483
+ },
1484
+ ],
1485
+ }
1486
+ : {}),
1487
+ result: result as ReadonlyJSONValue,
1488
+ isError: result["isError"] === true,
1489
+ }
1490
+ : {}),
1491
+ };
1492
+ });
1493
+ if (!matchedToolCall) return message;
1494
+ return { ...assistant, content };
1495
+ });
1496
+
1497
+ if (!updated) return;
1498
+ this.notifyUpdate();
1499
+ this.maybeCompleteAfterToolResults(messageId);
1500
+ }
1501
+
1384
1502
  private importMessagesSnapshot(rawMessages: readonly unknown[]) {
1385
1503
  try {
1386
1504
  const normalized = fromAgUiMessages(rawMessages, {
@@ -5,7 +5,11 @@ import type {
5
5
  ThreadMessageLike as CoreThreadMessageLike,
6
6
  ToolModelContentPart,
7
7
  } from "@assistant-ui/core";
8
- import { getAutoStatus } from "@assistant-ui/core/internal";
8
+ import {
9
+ getAutoStatus,
10
+ httpUrlPattern,
11
+ parseDataUrl,
12
+ } from "@assistant-ui/core/internal";
9
13
  import { type Tool, toToolsJSONSchema } from "assistant-stream";
10
14
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
11
15
  import {
@@ -13,6 +17,7 @@ import {
13
17
  type AgUiCustomMetadata,
14
18
  } from "./run-aggregator";
15
19
  import type { AgUiInterrupt } from "../types";
20
+ import { parseMcpToolCallResult } from "../mcp-tool-result";
16
21
 
17
22
  export type { InputContent };
18
23
 
@@ -129,16 +134,6 @@ function extractText(content: unknown): string {
129
134
  .join("\n");
130
135
  }
131
136
 
132
- function parseDataUrl(
133
- value: string,
134
- ): { mimeType: string; data: string } | null {
135
- const match = value.match(/^data:([^;,]+)(?:;[^;,]+)*;base64,(.+)$/);
136
- if (!match) return null;
137
- return { mimeType: match[1]!, data: match[2]! };
138
- }
139
-
140
- const httpUrlPattern = /^https?:\/\//i;
141
-
142
137
  type InputContentSource =
143
138
  | { type: "data"; value: string; mimeType: string }
144
139
  | { type: "url"; value: string; mimeType?: string };
@@ -540,12 +535,18 @@ export function fromAgUiMessages(
540
535
  if (role === "tool") {
541
536
  const toolCallId = getToolCallId(rawMessage) ?? `tool-${generateId()}`;
542
537
  const toolMessageId = getString(rawMessage, "id");
538
+ const modelContent = extractText(rawMessage.content);
539
+ const mcpResult = parseMcpToolCallResult(rawMessage, modelContent);
540
+ const mcpModelContent = mcpResult
541
+ ? [{ type: "text" as const, text: modelContent }]
542
+ : undefined;
543
543
  const result =
544
- rawMessage.result !== undefined
544
+ mcpResult ??
545
+ (rawMessage.result !== undefined
545
546
  ? rawMessage.result
546
547
  : typeof rawMessage.content === "string"
547
548
  ? parseJSONText(rawMessage.content)
548
- : rawMessage.content;
549
+ : rawMessage.content);
549
550
  const isError =
550
551
  typeof rawMessage.error === "string" ||
551
552
  rawMessage.isError === true ||
@@ -581,6 +582,7 @@ export function fromAgUiMessages(
581
582
  const updatedPart: ToolCallPart = {
582
583
  ...(part as ToolCallPart),
583
584
  result,
585
+ ...(mcpModelContent ? { modelContent: mcpModelContent } : {}),
584
586
  ...(isError !== undefined ? { isError } : {}),
585
587
  ...(toolMessageId !== undefined
586
588
  ? { unstable_toolMessageId: toolMessageId }
@@ -615,6 +617,7 @@ export function fromAgUiMessages(
615
617
  args: {},
616
618
  argsText: "{}",
617
619
  result,
620
+ ...(mcpModelContent ? { modelContent: mcpModelContent } : {}),
618
621
  ...(isError !== undefined ? { isError } : {}),
619
622
  ...(toolMessageId !== undefined
620
623
  ? { unstable_toolMessageId: toolMessageId }
@@ -719,10 +722,9 @@ function convertAssistantMessage(
719
722
  for (const { id: toolCallId, part } of toolCalls) {
720
723
  if (part.result === undefined) continue;
721
724
 
722
- const modelText = extractText(part.modelContent);
723
725
  const resultContent =
724
- modelText.length > 0
725
- ? modelText
726
+ part.modelContent !== undefined
727
+ ? extractText(part.modelContent)
726
728
  : typeof part.result === "string"
727
729
  ? part.result
728
730
  : JSON.stringify(part.result);
@@ -34,9 +34,11 @@ type ToolCallState = {
34
34
  snapshotResultApplied: boolean;
35
35
  };
36
36
 
37
- const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
37
+ export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
38
38
 
39
- const isPlainObject = (value: unknown): value is Record<string, unknown> =>
39
+ export const isPlainObject = (
40
+ value: unknown,
41
+ ): value is Record<string, unknown> =>
40
42
  !!value && typeof value === "object" && !Array.isArray(value);
41
43
 
42
44
  export type RunAggregatorOptions = {
@@ -235,8 +237,13 @@ export class RunAggregator {
235
237
  this.finishToolCall(
236
238
  event.toolCallId,
237
239
  event.content ?? "",
238
- event.role === "tool" ? false : undefined,
240
+ typeof event.mcpResult?.isError === "boolean"
241
+ ? event.mcpResult.isError
242
+ : event.role === "tool"
243
+ ? false
244
+ : undefined,
239
245
  event.messageId,
246
+ event.mcpResult,
240
247
  );
241
248
  this.emit();
242
249
  break;
@@ -442,6 +449,7 @@ export class RunAggregator {
442
449
  content: string,
443
450
  isError?: boolean,
444
451
  toolMessageId?: string,
452
+ mcpResult?: Record<string, unknown>,
445
453
  ) {
446
454
  if (!id) return;
447
455
  let entry = this.toolCalls.get(id);
@@ -464,7 +472,12 @@ export class RunAggregator {
464
472
  ) {
465
473
  this.partOrder.push({ kind: "tool-call", toolCallId: id });
466
474
  }
467
- if (entry.snapshotResultApplied) {
475
+ if (mcpResult !== undefined && !entry.snapshotResultApplied) {
476
+ entry.result = mcpResult;
477
+ entry.modelContent = [{ type: "text", text: content }];
478
+ entry.snapshotResultApplied = true;
479
+ entry.isError = isError;
480
+ } else if (entry.snapshotResultApplied) {
468
481
  if (entry.modelContent === undefined) {
469
482
  entry.modelContent = [{ type: "text", text: content }];
470
483
  }
@@ -1,5 +1,6 @@
1
1
  import type { AgUiEvent, AgUiInterrupt, AgUiRunFinishedOutcome } from "./types";
2
2
  import type { Logger } from "./logger";
3
+ import { parseMcpToolCallResult } from "./mcp-tool-result";
3
4
 
4
5
  export type ParseAgUiEventOptions = {
5
6
  logger?: Logger;
@@ -209,15 +210,17 @@ export const parseAgUiEvent = (
209
210
  case "TOOL_CALL_RESULT": {
210
211
  const toolCallId = getString("toolCallId");
211
212
  if (!toolCallId) return null;
213
+ const content = getString("content") ?? "";
212
214
  return withOptional(
213
215
  {
214
216
  type: "TOOL_CALL_RESULT" as const,
215
217
  toolCallId,
216
- content: getString("content") ?? "",
218
+ content,
217
219
  },
218
220
  {
219
221
  messageId: getString("messageId"),
220
222
  role: payload.role === "tool" ? "tool" : undefined,
223
+ mcpResult: parseMcpToolCallResult(payload, content),
221
224
  },
222
225
  );
223
226
  }
@@ -0,0 +1,26 @@
1
+ import { isRecord } from "@assistant-ui/core/internal";
2
+
3
+ export type McpToolCallResult = Record<string, unknown> & {
4
+ content: unknown[];
5
+ structuredContent?: Record<string, unknown>;
6
+ _meta?: Record<string, unknown>;
7
+ isError?: boolean;
8
+ };
9
+
10
+ export const parseMcpToolCallResult = (
11
+ source: Record<string, unknown>,
12
+ modelContent: string,
13
+ ): McpToolCallResult | undefined => {
14
+ const structuredContent = isRecord(source.structuredContent)
15
+ ? source.structuredContent
16
+ : undefined;
17
+ const meta = isRecord(source._meta) ? source._meta : undefined;
18
+ if (!structuredContent && !meta) return undefined;
19
+
20
+ return {
21
+ content: modelContent ? [{ type: "text", text: modelContent }] : [],
22
+ ...(structuredContent ? { structuredContent } : {}),
23
+ ...(meta ? { _meta: meta } : {}),
24
+ ...(typeof source.isError === "boolean" ? { isError: source.isError } : {}),
25
+ };
26
+ };
@@ -13,6 +13,7 @@ import type {
13
13
  import type { AbstractAgent } from "@ag-ui/client";
14
14
  import type { Logger } from "./logger";
15
15
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
16
+ import type { McpToolCallResult } from "./mcp-tool-result";
16
17
 
17
18
  /**
18
19
  * @experimental This API is still under active development and might change without notice.
@@ -162,6 +163,7 @@ export type AgUiEvent =
162
163
  toolCallId: string;
163
164
  content: string;
164
165
  role?: "tool";
166
+ mcpResult?: McpToolCallResult;
165
167
  }
166
168
  | {
167
169
  type: "ACTIVITY_SNAPSHOT";