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

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 (45) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +23 -2
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +169 -35
  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 +58 -26
  10. package/dist/runtime/adapter/conversions.js.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.d.ts +5 -1
  12. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  13. package/dist/runtime/adapter/run-aggregator.js +26 -1
  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 +3 -0
  31. package/dist/useAgUiRuntime.js.map +1 -1
  32. package/package.json +7 -7
  33. package/src/index.ts +8 -2
  34. package/src/runtime/AgUiThreadRuntimeCore.ts +314 -41
  35. package/src/runtime/adapter/conversions.content.test.ts +61 -3
  36. package/src/runtime/adapter/conversions.ts +83 -39
  37. package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
  38. package/src/runtime/adapter/run-aggregator.ts +37 -1
  39. package/src/runtime/adapter/subscriber.ts +14 -0
  40. package/src/runtime/adapter/tool-approval.test.ts +681 -0
  41. package/src/runtime/adapter/tool-approval.ts +366 -0
  42. package/src/runtime/event-parser.ts +10 -1
  43. package/src/runtime/interrupt-internals.ts +35 -0
  44. package/src/useAgUiRuntime.approval.test.tsx +885 -0
  45. package/src/useAgUiRuntime.ts +4 -0
@@ -27,6 +27,7 @@ import {
27
27
  type A2uiSurfaceState,
28
28
  } from "@assistant-ui/react-generative-ui/a2ui";
29
29
  import type { AgUiInterrupt } from "../types";
30
+ import { projectAgUiToolApprovals } from "./tool-approval";
30
31
  import {
31
32
  parseMcpToolCallResult,
32
33
  readMcpAppResourceUri,
@@ -41,7 +42,7 @@ type AttachmentLike = {
41
42
  };
42
43
 
43
44
  type ThreadMessageLike = {
44
- id: string;
45
+ id?: string;
45
46
  role: string;
46
47
  content: unknown;
47
48
  metadata?: unknown;
@@ -51,6 +52,8 @@ type ThreadMessageLike = {
51
52
  attachments?: readonly AttachmentLike[];
52
53
  };
53
54
 
55
+ type NormalizedThreadMessageLike = ThreadMessageLike & { id: string };
56
+
54
57
  type AgUiToolCall = {
55
58
  id: string;
56
59
  type: "function";
@@ -60,11 +63,23 @@ type AgUiToolCall = {
60
63
  export type AgUiMessage =
61
64
  | {
62
65
  id: string;
63
- role: string;
66
+ role: "user";
64
67
  content: string | InputContent[];
65
68
  name?: string;
69
+ }
70
+ | {
71
+ id: string;
72
+ role: "assistant";
73
+ content: string;
74
+ name?: string;
66
75
  toolCalls?: AgUiToolCall[];
67
76
  }
77
+ | {
78
+ id: string;
79
+ role: "system" | "developer";
80
+ content: string;
81
+ name?: string;
82
+ }
68
83
  | {
69
84
  id: string;
70
85
  role: "reasoning";
@@ -606,13 +621,27 @@ function toAssistantSnapshotMessage(
606
621
  rawMessage: Record<string, unknown>,
607
622
  ): CoreThreadMessageLike {
608
623
  const text = extractText(rawMessage.content);
609
- const toolCallParts = extractAssistantToolCalls(rawMessage);
624
+ const interrupts = readPersistedInterrupts(rawMessage.metadata);
625
+ const restoredToolCalls = extractAssistantToolCalls(rawMessage);
626
+ const approvals = projectAgUiToolApprovals(
627
+ interrupts,
628
+ new Set(
629
+ restoredToolCalls
630
+ .map((part) => part.toolCallId)
631
+ .filter((id): id is string => !!id),
632
+ ),
633
+ );
634
+ const toolCallParts = restoredToolCalls.map((part) => {
635
+ const approval = part.toolCallId
636
+ ? approvals.get(part.toolCallId)
637
+ : undefined;
638
+ return approval ? { ...part, approval } : part;
639
+ });
610
640
  const assistantContent = [
611
641
  ...(text.length > 0 ? [{ type: "text" as const, text }] : []),
612
642
  ...toolCallParts,
613
643
  ];
614
644
  const messageName = getString(rawMessage, "name");
615
- const interrupts = readPersistedInterrupts(rawMessage.metadata);
616
645
  return {
617
646
  id: getString(rawMessage, "id") ?? generateId(),
618
647
  role: "assistant",
@@ -960,7 +989,7 @@ export function fromAgUiMessages(
960
989
  }
961
990
 
962
991
  function convertAssistantMessage(
963
- message: ThreadMessageLike,
992
+ message: NormalizedThreadMessageLike,
964
993
  converted: AgUiMessage[],
965
994
  ): void {
966
995
  const content = extractText(message.content);
@@ -1012,18 +1041,15 @@ function convertAssistantMessage(
1012
1041
  return;
1013
1042
  }
1014
1043
 
1015
- const assistantMessage: AgUiMessage = {
1044
+ converted.push({
1016
1045
  id: message.id,
1017
1046
  role: "assistant",
1018
1047
  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);
1048
+ ...(message.name ? { name: message.name } : {}),
1049
+ ...(toolCalls.length > 0
1050
+ ? { toolCalls: toolCalls.map((entry) => entry.call) }
1051
+ : {}),
1052
+ });
1027
1053
 
1028
1054
  for (const { id: toolCallId, part } of toolCalls) {
1029
1055
  if (part.result === undefined) continue;
@@ -1035,36 +1061,30 @@ function convertAssistantMessage(
1035
1061
  ? part.result
1036
1062
  : JSON.stringify(part.result);
1037
1063
 
1038
- const toolMessage: AgUiMessage = {
1064
+ converted.push({
1039
1065
  id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
1040
1066
  role: "tool",
1041
1067
  content: resultContent,
1042
1068
  toolCallId,
1043
- };
1044
- if (part.isError) {
1045
- toolMessage.error = resultContent;
1046
- }
1047
- converted.push(toolMessage);
1069
+ ...(part.isError ? { error: resultContent } : {}),
1070
+ });
1048
1071
  }
1049
1072
  }
1050
1073
 
1051
1074
  function convertToolMessage(
1052
- message: ThreadMessageLike,
1075
+ message: NormalizedThreadMessageLike,
1053
1076
  converted: AgUiMessage[],
1054
1077
  ): void {
1055
1078
  const content = extractText(message.content);
1056
1079
  const toolCallId = message.toolCallId ?? generateId();
1057
1080
 
1058
- const toolMessage: AgUiMessage = {
1081
+ converted.push({
1059
1082
  id: message.id,
1060
1083
  role: "tool",
1061
1084
  content,
1062
1085
  toolCallId,
1063
- };
1064
- if (typeof message.error === "string") {
1065
- toolMessage.error = message.error;
1066
- }
1067
- converted.push(toolMessage);
1086
+ ...(typeof message.error === "string" ? { error: message.error } : {}),
1087
+ });
1068
1088
  }
1069
1089
 
1070
1090
  export function toAgUiMessages(
@@ -1072,7 +1092,11 @@ export function toAgUiMessages(
1072
1092
  ): AgUiMessage[] {
1073
1093
  const converted: AgUiMessage[] = [];
1074
1094
 
1075
- for (const message of messages) {
1095
+ for (const rawMessage of messages) {
1096
+ const message: NormalizedThreadMessageLike = {
1097
+ ...rawMessage,
1098
+ id: rawMessage.id ?? generateId(),
1099
+ };
1076
1100
  const opaqueReasoning = readOpaqueReasoning(message.metadata);
1077
1101
  const toOpaqueRecord = (entry: AgUiOpaqueReasoning): AgUiMessage => ({
1078
1102
  id: entry.id,
@@ -1101,18 +1125,38 @@ export function toAgUiMessages(
1101
1125
  continue;
1102
1126
  }
1103
1127
 
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;
1128
+ if (message.role === "user") {
1129
+ converted.push({
1130
+ id: message.id,
1131
+ role: "user",
1132
+ content: buildUserContent(message),
1133
+ ...(message.name ? { name: message.name } : {}),
1134
+ });
1135
+ flushTrailingOpaqueReasoning();
1136
+ continue;
1137
+ }
1138
+
1139
+ if (message.role === "system" || message.role === "developer") {
1140
+ converted.push({
1141
+ id: message.id,
1142
+ role: message.role,
1143
+ content: extractText(message.content),
1144
+ ...(message.name ? { name: message.name } : {}),
1145
+ });
1146
+ flushTrailingOpaqueReasoning();
1147
+ continue;
1148
+ }
1149
+
1150
+ if (message.role === "reasoning") {
1151
+ converted.push({
1152
+ id: message.id,
1153
+ role: "reasoning",
1154
+ content: extractText(message.content),
1155
+ });
1156
+ flushTrailingOpaqueReasoning();
1157
+ continue;
1114
1158
  }
1115
- converted.push(genericMessage);
1159
+
1116
1160
  flushTrailingOpaqueReasoning();
1117
1161
  }
1118
1162
 
@@ -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
+ });
@@ -15,6 +15,7 @@ import {
15
15
  type A2uiSurfaceState,
16
16
  } from "@assistant-ui/react-generative-ui/a2ui";
17
17
  import { readMcpAppResourceUri } from "../mcp-tool-result";
18
+ import { projectAgUiToolApprovals } from "./tool-approval";
18
19
  import type { AgUiEvent, AgUiInterrupt } from "../types";
19
20
  import type { Logger } from "../logger";
20
21
 
@@ -68,7 +69,11 @@ export type RunAggregatorOptions = {
68
69
  * Collects AG-UI events into assistant-ui run snapshots that can be yielded from a ChatModelAdapter.
69
70
  *
70
71
  * The aggregator keeps a single assistant message worth of parts. Each incoming event updates the parts and
71
- * emits a fresh snapshot through the provided `emit` callback.
72
+ * emits a fresh snapshot through the provided `emit` callback. `CUSTOM` events
73
+ * become canonical data parts; integration plumbing names such as
74
+ * `on_interrupt`, `PredictState`, `Exit`, `hook_error`, `state_update_error`,
75
+ * `system:*`, and `MultiAgentHandoff` are forwarded the same way, so apps only
76
+ * need data renderers for names they own.
72
77
  */
73
78
  export class RunAggregator {
74
79
  private readonly emitUpdate: Emit;
@@ -97,6 +102,7 @@ export class RunAggregator {
97
102
  | { kind: "text"; key: string }
98
103
  | { kind: "reasoning"; key: string }
99
104
  | { kind: "tool-call"; toolCallId: string }
105
+ | { kind: "data"; name: string; value: unknown }
100
106
  )[] = [];
101
107
  private textPartCounter = 0;
102
108
  private serverMessageIdReported = false;
@@ -207,6 +213,17 @@ export class RunAggregator {
207
213
  break;
208
214
  }
209
215
 
216
+ case "CUSTOM": {
217
+ this.activeTextMessageId = undefined;
218
+ this.partOrder.push({
219
+ kind: "data",
220
+ name: event.name,
221
+ value: event.value,
222
+ });
223
+ this.emit();
224
+ break;
225
+ }
226
+
210
227
  case "THINKING_START":
211
228
  case "THINKING_TEXT_MESSAGE_START":
212
229
  case "REASONING_START":
@@ -614,6 +631,14 @@ export class RunAggregator {
614
631
 
615
632
  private emit(): void {
616
633
  const snapshot: ThreadAssistantMessagePart[] = [];
634
+ // A run that ended incomplete can no longer be resumed, so a gate left over
635
+ // from an earlier interrupt outcome is unanswerable and must not stay
636
+ // projected. The interrupts themselves are kept on the message, since the
637
+ // bespoke hooks read that payload.
638
+ const approvals = projectAgUiToolApprovals(
639
+ this.status?.type === "requires-action" ? this.interrupts : undefined,
640
+ new Set(this.toolCalls.keys()),
641
+ );
617
642
 
618
643
  for (const part of this.partOrder) {
619
644
  if (part.kind === "reasoning") {
@@ -646,14 +671,25 @@ export class RunAggregator {
646
671
  continue;
647
672
  }
648
673
 
674
+ if (part.kind === "data") {
675
+ snapshot.push({
676
+ type: "data",
677
+ name: part.name,
678
+ data: part.value,
679
+ });
680
+ continue;
681
+ }
682
+
649
683
  const entry = this.toolCalls.get(part.toolCallId);
650
684
  if (!entry) continue;
685
+ const approval = approvals.get(entry.toolCallId);
651
686
  const toolPart: ToolCallMessagePart = {
652
687
  type: "tool-call",
653
688
  toolCallId: entry.toolCallId,
654
689
  toolName: entry.toolCallName,
655
690
  args: (entry.parsedArgs ?? {}) as any,
656
691
  argsText: entry.argsText,
692
+ ...(approval ? { approval } : {}),
657
693
  ...(entry.result !== undefined ? { result: entry.result } : {}),
658
694
  ...(entry.modelContent !== undefined
659
695
  ? { modelContent: entry.modelContent }
@@ -35,6 +35,7 @@ type Subscriber = {
35
35
  onCustomEvent?: (payload: { event: unknown }) => void;
36
36
  onRawEvent?: (payload: { event: unknown }) => void;
37
37
  onRunFinishedEvent?: (payload: { event: unknown }) => void;
38
+ onRunErrorEvent?: (payload: { event: unknown }) => void;
38
39
  onRunFinalized?: () => void;
39
40
  onRunFailed?: (payload: { error: Error }) => void;
40
41
  };
@@ -146,11 +147,24 @@ export const createAgUiSubscriber = (
146
147
  onCustomEvent: ({ event }) => dispatchIfValid(event, "CUSTOM"),
147
148
  onRawEvent: ({ event }) => dispatchIfValid(event, "RAW"),
148
149
  onRunFinishedEvent: ({ event }) => {
150
+ if (runFinishedDispatched) return;
149
151
  const parsed = ensureEvent(event, "RUN_FINISHED", logger);
150
152
  if (!parsed) return;
151
153
  runFinishedDispatched = true;
152
154
  dispatch(parsed);
153
155
  },
156
+ onRunErrorEvent: ({ event }) => {
157
+ const parsed = ensureEvent(event, "RUN_ERROR", logger);
158
+ if (!parsed || parsed.type !== "RUN_ERROR") return;
159
+ runFinishedDispatched = true;
160
+ dispatch(parsed);
161
+
162
+ const error = Object.assign(
163
+ new Error(parsed.message ?? "Run failed"),
164
+ parsed.code === undefined ? {} : { code: parsed.code },
165
+ );
166
+ onRunFailed?.(error);
167
+ },
154
168
  onRunFinalized: () => {
155
169
  if (runFinishedDispatched) return;
156
170
  dispatch({ type: "RUN_FINISHED", runId });