@assistant-ui/react-ag-ui 0.0.54 → 0.0.57

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 (47) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +25 -15
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +257 -210
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts +13 -3
  8. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  9. package/dist/runtime/adapter/conversions.js +77 -39
  10. package/dist/runtime/adapter/conversions.js.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.d.ts +23 -2
  12. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  13. package/dist/runtime/adapter/run-aggregator.js +155 -41
  14. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  15. package/dist/runtime/adapter/subscriber.d.ts +3 -0
  16. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  17. package/dist/runtime/adapter/subscriber.js +9 -0
  18. package/dist/runtime/adapter/subscriber.js.map +1 -1
  19. package/dist/runtime/adapter/tool-approval.d.ts +56 -0
  20. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -0
  21. package/dist/runtime/adapter/tool-approval.js +244 -0
  22. package/dist/runtime/adapter/tool-approval.js.map +1 -0
  23. package/dist/runtime/event-parser.d.ts.map +1 -1
  24. package/dist/runtime/event-parser.js +2 -1
  25. package/dist/runtime/event-parser.js.map +1 -1
  26. package/dist/runtime/interrupt-internals.d.ts +8 -0
  27. package/dist/runtime/interrupt-internals.d.ts.map +1 -0
  28. package/dist/runtime/interrupt-internals.js +25 -0
  29. package/dist/runtime/interrupt-internals.js.map +1 -0
  30. package/dist/useAgUiRuntime.js +14 -9
  31. package/dist/useAgUiRuntime.js.map +1 -1
  32. package/package.json +9 -9
  33. package/src/index.ts +8 -2
  34. package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
  35. package/src/runtime/AgUiThreadRuntimeCore.ts +436 -272
  36. package/src/runtime/adapter/conversions.content.test.ts +61 -3
  37. package/src/runtime/adapter/conversions.ts +123 -57
  38. package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
  39. package/src/runtime/adapter/run-aggregator.ts +234 -47
  40. package/src/runtime/adapter/subscriber.ts +14 -0
  41. package/src/runtime/adapter/tool-approval.test.ts +681 -0
  42. package/src/runtime/adapter/tool-approval.ts +366 -0
  43. package/src/runtime/event-parser.ts +10 -1
  44. package/src/runtime/interrupt-internals.ts +35 -0
  45. package/src/useAgUiRuntime.approval.test.tsx +885 -0
  46. package/src/useAgUiRuntime.history.test.tsx +76 -0
  47. package/src/useAgUiRuntime.ts +15 -9
@@ -1,6 +1,15 @@
1
- import { describe, expect, it } from "vitest";
2
- import { UserMessageSchema } from "@ag-ui/client";
3
- import { fromAgUiMessages, toAgUiMessages } from "./conversions";
1
+ import { describe, expect, expectTypeOf, it } from "vitest";
2
+ import {
3
+ MessageSchema,
4
+ UserMessageSchema,
5
+ type Message as AgUiWireMessage,
6
+ } from "@ag-ui/client";
7
+ import type { AppendMessage } from "@assistant-ui/core";
8
+ import {
9
+ fromAgUiMessages,
10
+ toAgUiMessages,
11
+ type AgUiMessage,
12
+ } from "./conversions";
4
13
 
5
14
  type Message = Parameters<typeof toAgUiMessages>[0][number];
6
15
 
@@ -20,7 +29,56 @@ const contentOf = (message: Message) => {
20
29
  return (converted as { content: unknown }).content;
21
30
  };
22
31
 
32
+ const appendMessage = (): AppendMessage => ({
33
+ role: "user",
34
+ content: [{ type: "text", text: "Hi" }],
35
+ attachments: [],
36
+ createdAt: new Date(),
37
+ parentId: null,
38
+ sourceId: null,
39
+ runConfig: undefined,
40
+ metadata: { custom: {} },
41
+ });
42
+
23
43
  describe("toAgUiMessages content metadata", () => {
44
+ it("emits AgUiMessage values assignable to AG-UI Message", () => {
45
+ expectTypeOf<AgUiMessage>().toExtend<AgUiWireMessage>();
46
+ });
47
+
48
+ it("emits records MessageSchema accepts", () => {
49
+ const converted = toAgUiMessages([
50
+ { id: "u-1", role: "user", content: "Hi" },
51
+ { id: "a-1", role: "assistant", content: "Hello" },
52
+ { id: "s-1", role: "system", content: "Be brief" },
53
+ { id: "t-1", role: "tool", content: "ok", toolCallId: "c-1" },
54
+ { id: "r-1", role: "reasoning", content: "hmm" },
55
+ ]);
56
+
57
+ expect(converted.map((message) => MessageSchema.parse(message))).toEqual(
58
+ converted,
59
+ );
60
+ });
61
+
62
+ it("normalizes an AppendMessage without an id", () => {
63
+ const converted = toAgUiMessages([appendMessage()])[0];
64
+ if (!converted) throw new Error("expected a converted message");
65
+
66
+ expect(UserMessageSchema.parse(converted)).toMatchObject({
67
+ role: "user",
68
+ content: "Hi",
69
+ });
70
+ expect(converted.id).toEqual(expect.any(String));
71
+ });
72
+
73
+ it("preserves a caller-supplied id", () => {
74
+ const converted = toAgUiMessages([
75
+ { ...appendMessage(), id: "stable-user-1" },
76
+ ])[0];
77
+ if (!converted) throw new Error("expected a converted message");
78
+
79
+ expect(UserMessageSchema.parse(converted).id).toBe("stable-user-1");
80
+ });
81
+
24
82
  it("carries a part's agui provider metadata onto an image item", () => {
25
83
  expect(
26
84
  contentOf(
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { InputContent, RunAgentParameters } from "@ag-ui/client";
4
+ import { generateId } from "@assistant-ui/core";
4
5
  import type {
5
6
  ThreadMessageLike as CoreThreadMessageLike,
6
7
  PartProviderMetadata,
@@ -9,8 +10,8 @@ import type {
9
10
  } from "@assistant-ui/core";
10
11
  import {
11
12
  getAutoStatus,
12
- httpUrlPattern,
13
13
  parseDataUrl,
14
+ resolveFilePartSource,
14
15
  } from "@assistant-ui/core/internal";
15
16
  import { type Tool, toToolsJSONSchema } from "assistant-stream";
16
17
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
@@ -27,6 +28,7 @@ import {
27
28
  type A2uiSurfaceState,
28
29
  } from "@assistant-ui/react-generative-ui/a2ui";
29
30
  import type { AgUiInterrupt } from "../types";
31
+ import { projectAgUiToolApprovals } from "./tool-approval";
30
32
  import {
31
33
  parseMcpToolCallResult,
32
34
  readMcpAppResourceUri,
@@ -41,7 +43,7 @@ type AttachmentLike = {
41
43
  };
42
44
 
43
45
  type ThreadMessageLike = {
44
- id: string;
46
+ id?: string;
45
47
  role: string;
46
48
  content: unknown;
47
49
  metadata?: unknown;
@@ -51,6 +53,8 @@ type ThreadMessageLike = {
51
53
  attachments?: readonly AttachmentLike[];
52
54
  };
53
55
 
56
+ type NormalizedThreadMessageLike = ThreadMessageLike & { id: string };
57
+
54
58
  type AgUiToolCall = {
55
59
  id: string;
56
60
  type: "function";
@@ -60,11 +64,23 @@ type AgUiToolCall = {
60
64
  export type AgUiMessage =
61
65
  | {
62
66
  id: string;
63
- role: string;
67
+ role: "user";
64
68
  content: string | InputContent[];
65
69
  name?: string;
70
+ }
71
+ | {
72
+ id: string;
73
+ role: "assistant";
74
+ content: string;
75
+ name?: string;
66
76
  toolCalls?: AgUiToolCall[];
67
77
  }
78
+ | {
79
+ id: string;
80
+ role: "system" | "developer";
81
+ content: string;
82
+ name?: string;
83
+ }
68
84
  | {
69
85
  id: string;
70
86
  role: "reasoning";
@@ -162,13 +178,6 @@ function parseJSONText(value: string): unknown {
162
178
  }
163
179
  }
164
180
 
165
- function generateId(): string {
166
- return (
167
- (globalThis.crypto as { randomUUID?: () => string })?.randomUUID?.() ??
168
- Math.random().toString(36).slice(2)
169
- );
170
- }
171
-
172
181
  function normalizeToolCall(part: ToolCallPart): {
173
182
  id: string;
174
183
  call: AgUiToolCall;
@@ -227,17 +236,20 @@ function buildInputSource(
227
236
  declaredMimeType: string | undefined,
228
237
  sourceType?: string,
229
238
  ): InputContentSource {
230
- if (sourceType === "url" || httpUrlPattern.test(value)) {
239
+ const source = resolveFilePartSource({
240
+ data: value,
241
+ mimeType: declaredMimeType ?? "application/octet-stream",
242
+ sourceType,
243
+ });
244
+ if (source.kind === "url") {
231
245
  return declaredMimeType !== undefined
232
- ? { type: "url", value, mimeType: declaredMimeType }
233
- : { type: "url", value };
246
+ ? { type: "url", value: source.url, mimeType: declaredMimeType }
247
+ : { type: "url", value: source.url };
234
248
  }
235
- const parsed = parseDataUrl(value);
236
249
  return {
237
250
  type: "data",
238
- value: parsed?.data ?? value,
239
- mimeType:
240
- parsed?.mimeType ?? declaredMimeType ?? "application/octet-stream",
251
+ value: source.data,
252
+ mimeType: source.mimeType,
241
253
  };
242
254
  }
243
255
 
@@ -606,13 +618,27 @@ function toAssistantSnapshotMessage(
606
618
  rawMessage: Record<string, unknown>,
607
619
  ): CoreThreadMessageLike {
608
620
  const text = extractText(rawMessage.content);
609
- const toolCallParts = extractAssistantToolCalls(rawMessage);
621
+ const interrupts = readPersistedInterrupts(rawMessage.metadata);
622
+ const restoredToolCalls = extractAssistantToolCalls(rawMessage);
623
+ const approvals = projectAgUiToolApprovals(
624
+ interrupts,
625
+ new Set(
626
+ restoredToolCalls
627
+ .map((part) => part.toolCallId)
628
+ .filter((id): id is string => !!id),
629
+ ),
630
+ );
631
+ const toolCallParts = restoredToolCalls.map((part) => {
632
+ const approval = part.toolCallId
633
+ ? approvals.get(part.toolCallId)
634
+ : undefined;
635
+ return approval ? { ...part, approval } : part;
636
+ });
610
637
  const assistantContent = [
611
638
  ...(text.length > 0 ? [{ type: "text" as const, text }] : []),
612
639
  ...toolCallParts,
613
640
  ];
614
641
  const messageName = getString(rawMessage, "name");
615
- const interrupts = readPersistedInterrupts(rawMessage.metadata);
616
642
  return {
617
643
  id: getString(rawMessage, "id") ?? generateId(),
618
644
  role: "assistant",
@@ -633,7 +659,7 @@ function toAssistantSnapshotMessage(
633
659
  }
634
660
 
635
661
  function toUserOrSystemSnapshotMessage(
636
- role: "user" | "system",
662
+ role: "user" | "system" | "developer",
637
663
  rawMessage: Record<string, unknown>,
638
664
  ): CoreThreadMessageLike {
639
665
  const messageName = getString(rawMessage, "name");
@@ -641,10 +667,23 @@ function toUserOrSystemSnapshotMessage(
641
667
  role === "user" ? toSnapshotAttachments(rawMessage.content) : [];
642
668
  return {
643
669
  id: getString(rawMessage, "id") ?? generateId(),
644
- role,
670
+ // The internal message model has no developer role; it rides as a system
671
+ // message with its wire role kept in metadata so the export restores it.
672
+ role: role === "developer" ? "system" : role,
645
673
  content: extractText(rawMessage.content),
646
674
  ...(messageName !== undefined ? { name: messageName } : {}),
647
675
  ...(attachments.length > 0 ? { attachments } : {}),
676
+ ...(role === "developer"
677
+ ? {
678
+ metadata: {
679
+ custom: {
680
+ [AG_UI_METADATA_NAMESPACE]: {
681
+ role: "developer",
682
+ } satisfies AgUiCustomMetadata,
683
+ },
684
+ },
685
+ }
686
+ : {}),
648
687
  };
649
688
  }
650
689
 
@@ -910,7 +949,7 @@ export function fromAgUiMessages(
910
949
  continue;
911
950
  }
912
951
 
913
- if (role === "user" || role === "system") {
952
+ if (role === "user" || role === "system" || role === "developer") {
914
953
  converted.push(toUserOrSystemSnapshotMessage(role, rawMessage));
915
954
  }
916
955
  }
@@ -960,7 +999,7 @@ export function fromAgUiMessages(
960
999
  }
961
1000
 
962
1001
  function convertAssistantMessage(
963
- message: ThreadMessageLike,
1002
+ message: NormalizedThreadMessageLike,
964
1003
  converted: AgUiMessage[],
965
1004
  ): void {
966
1005
  const content = extractText(message.content);
@@ -1012,18 +1051,15 @@ function convertAssistantMessage(
1012
1051
  return;
1013
1052
  }
1014
1053
 
1015
- const assistantMessage: AgUiMessage = {
1054
+ converted.push({
1016
1055
  id: message.id,
1017
1056
  role: "assistant",
1018
1057
  content,
1019
- };
1020
- if (message.name) {
1021
- assistantMessage.name = message.name;
1022
- }
1023
- if (toolCalls.length > 0) {
1024
- assistantMessage.toolCalls = toolCalls.map((entry) => entry.call);
1025
- }
1026
- converted.push(assistantMessage);
1058
+ ...(message.name ? { name: message.name } : {}),
1059
+ ...(toolCalls.length > 0
1060
+ ? { toolCalls: toolCalls.map((entry) => entry.call) }
1061
+ : {}),
1062
+ });
1027
1063
 
1028
1064
  for (const { id: toolCallId, part } of toolCalls) {
1029
1065
  if (part.result === undefined) continue;
@@ -1035,36 +1071,30 @@ function convertAssistantMessage(
1035
1071
  ? part.result
1036
1072
  : JSON.stringify(part.result);
1037
1073
 
1038
- const toolMessage: AgUiMessage = {
1074
+ converted.push({
1039
1075
  id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
1040
1076
  role: "tool",
1041
1077
  content: resultContent,
1042
1078
  toolCallId,
1043
- };
1044
- if (part.isError) {
1045
- toolMessage.error = resultContent;
1046
- }
1047
- converted.push(toolMessage);
1079
+ ...(part.isError ? { error: resultContent } : {}),
1080
+ });
1048
1081
  }
1049
1082
  }
1050
1083
 
1051
1084
  function convertToolMessage(
1052
- message: ThreadMessageLike,
1085
+ message: NormalizedThreadMessageLike,
1053
1086
  converted: AgUiMessage[],
1054
1087
  ): void {
1055
1088
  const content = extractText(message.content);
1056
1089
  const toolCallId = message.toolCallId ?? generateId();
1057
1090
 
1058
- const toolMessage: AgUiMessage = {
1091
+ converted.push({
1059
1092
  id: message.id,
1060
1093
  role: "tool",
1061
1094
  content,
1062
1095
  toolCallId,
1063
- };
1064
- if (typeof message.error === "string") {
1065
- toolMessage.error = message.error;
1066
- }
1067
- converted.push(toolMessage);
1096
+ ...(typeof message.error === "string" ? { error: message.error } : {}),
1097
+ });
1068
1098
  }
1069
1099
 
1070
1100
  export function toAgUiMessages(
@@ -1072,7 +1102,11 @@ export function toAgUiMessages(
1072
1102
  ): AgUiMessage[] {
1073
1103
  const converted: AgUiMessage[] = [];
1074
1104
 
1075
- for (const message of messages) {
1105
+ for (const rawMessage of messages) {
1106
+ const message: NormalizedThreadMessageLike = {
1107
+ ...rawMessage,
1108
+ id: rawMessage.id ?? generateId(),
1109
+ };
1076
1110
  const opaqueReasoning = readOpaqueReasoning(message.metadata);
1077
1111
  const toOpaqueRecord = (entry: AgUiOpaqueReasoning): AgUiMessage => ({
1078
1112
  id: entry.id,
@@ -1101,18 +1135,50 @@ export function toAgUiMessages(
1101
1135
  continue;
1102
1136
  }
1103
1137
 
1104
- const genericMessage: AgUiMessage = {
1105
- id: message.id,
1106
- role: message.role,
1107
- content:
1108
- message.role === "user"
1109
- ? buildUserContent(message)
1110
- : extractText(message.content),
1111
- };
1112
- if (message.name) {
1113
- genericMessage.name = message.name;
1138
+ if (message.role === "user") {
1139
+ converted.push({
1140
+ id: message.id,
1141
+ role: "user",
1142
+ content: buildUserContent(message),
1143
+ ...(message.name ? { name: message.name } : {}),
1144
+ });
1145
+ flushTrailingOpaqueReasoning();
1146
+ continue;
1114
1147
  }
1115
- converted.push(genericMessage);
1148
+
1149
+ if (message.role === "system" || message.role === "developer") {
1150
+ const custom = isObject(message.metadata)
1151
+ ? message.metadata.custom
1152
+ : undefined;
1153
+ const namespaced = isObject(custom)
1154
+ ? custom[AG_UI_METADATA_NAMESPACE]
1155
+ : undefined;
1156
+ const wireRole =
1157
+ message.role === "system" &&
1158
+ isObject(namespaced) &&
1159
+ namespaced.role === "developer"
1160
+ ? ("developer" as const)
1161
+ : message.role;
1162
+ converted.push({
1163
+ id: message.id,
1164
+ role: wireRole,
1165
+ content: extractText(message.content),
1166
+ ...(message.name ? { name: message.name } : {}),
1167
+ });
1168
+ flushTrailingOpaqueReasoning();
1169
+ continue;
1170
+ }
1171
+
1172
+ if (message.role === "reasoning") {
1173
+ converted.push({
1174
+ id: message.id,
1175
+ role: "reasoning",
1176
+ content: extractText(message.content),
1177
+ });
1178
+ flushTrailingOpaqueReasoning();
1179
+ continue;
1180
+ }
1181
+
1116
1182
  flushTrailingOpaqueReasoning();
1117
1183
  }
1118
1184
 
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { ChatModelRunResult } from "@assistant-ui/core";
3
+ import { RunAggregator } from "./run-aggregator";
4
+ import type { AgUiInterrupt } from "../types";
5
+
6
+ const GATE: AgUiInterrupt = {
7
+ id: "int-1",
8
+ reason: "tool_call",
9
+ toolCallId: "tc-1",
10
+ message: "Delete /tmp/a?",
11
+ };
12
+
13
+ const noopLogger = {
14
+ debug: vi.fn(),
15
+ info: vi.fn(),
16
+ warn: vi.fn(),
17
+ error: vi.fn(),
18
+ } as any;
19
+
20
+ const gatedAggregator = () => {
21
+ const emitted: ChatModelRunResult[] = [];
22
+ const aggregator = new RunAggregator({
23
+ showThinking: false,
24
+ logger: noopLogger,
25
+ emit: (result) => {
26
+ emitted.push(result);
27
+ },
28
+ });
29
+
30
+ aggregator.handle({ type: "RUN_STARTED", runId: "run-1" });
31
+ aggregator.handle({
32
+ type: "TOOL_CALL_START",
33
+ toolCallId: "tc-1",
34
+ toolCallName: "delete_file",
35
+ });
36
+ aggregator.handle({
37
+ type: "TOOL_CALL_ARGS",
38
+ toolCallId: "tc-1",
39
+ delta: '{"path":"/tmp/a"}',
40
+ });
41
+ aggregator.handle({ type: "TOOL_CALL_END", toolCallId: "tc-1" });
42
+ aggregator.handle({
43
+ type: "RUN_FINISHED",
44
+ runId: "run-1",
45
+ outcome: { type: "interrupt", interrupts: [GATE] },
46
+ } as any);
47
+
48
+ return { aggregator, emitted, last: () => emitted[emitted.length - 1]! };
49
+ };
50
+
51
+ const approvalOf = (result: ChatModelRunResult) => {
52
+ const part = (result.content ?? []).find((p) => p.type === "tool-call");
53
+ return (part as { approval?: unknown } | undefined)?.approval;
54
+ };
55
+
56
+ describe("RunAggregator tool approval projection", () => {
57
+ it("projects the gate while the run awaits the interrupt", () => {
58
+ const { last } = gatedAggregator();
59
+
60
+ expect(last().status).toMatchObject({
61
+ type: "requires-action",
62
+ reason: "interrupt",
63
+ });
64
+ expect(approvalOf(last())).toMatchObject({ id: "int-1" });
65
+ });
66
+
67
+ /**
68
+ * The gate cannot be clicked once the run is unresumable, but the interrupts
69
+ * stay on the message: the bespoke hooks read that payload, and dropping it
70
+ * would take a shipped surface away with the projection.
71
+ */
72
+ it("drops the gate when the run then errors, keeping the interrupts", () => {
73
+ const { aggregator, last } = gatedAggregator();
74
+
75
+ aggregator.handle({ type: "RUN_ERROR", message: "stream dropped" } as any);
76
+
77
+ expect(last().status).toMatchObject({
78
+ type: "incomplete",
79
+ reason: "error",
80
+ });
81
+ expect(approvalOf(last())).toBeUndefined();
82
+ expect(
83
+ (last().metadata?.custom as Record<string, any> | undefined)?.agui
84
+ ?.interrupts,
85
+ ).toEqual([GATE]);
86
+ });
87
+
88
+ it("drops the gate when the run is then cancelled, keeping the interrupts", () => {
89
+ const { aggregator, last } = gatedAggregator();
90
+
91
+ aggregator.handle({ type: "RUN_CANCELLED" } as any);
92
+
93
+ expect(last().status).toMatchObject({
94
+ type: "incomplete",
95
+ reason: "cancelled",
96
+ });
97
+ expect(approvalOf(last())).toBeUndefined();
98
+ expect(
99
+ (last().metadata?.custom as Record<string, any> | undefined)?.agui
100
+ ?.interrupts,
101
+ ).toEqual([GATE]);
102
+ });
103
+ });