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

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 (31) hide show
  1. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  2. package/dist/runtime/AgUiThreadRuntimeCore.js +14 -4
  3. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  4. package/dist/runtime/adapter/conversions.d.ts +6 -0
  5. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  6. package/dist/runtime/adapter/conversions.js +139 -10
  7. package/dist/runtime/adapter/conversions.js.map +1 -1
  8. package/dist/runtime/adapter/run-aggregator.d.ts +10 -1
  9. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.js +36 -9
  11. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  12. package/dist/runtime/adapter/subscriber.d.ts +3 -0
  13. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  14. package/dist/runtime/adapter/subscriber.js +1 -0
  15. package/dist/runtime/adapter/subscriber.js.map +1 -1
  16. package/dist/runtime/event-parser.js +13 -0
  17. package/dist/runtime/event-parser.js.map +1 -1
  18. package/dist/runtime/types.d.ts +5 -0
  19. package/dist/runtime/types.d.ts.map +1 -1
  20. package/dist/useAgUiRuntime.js +1 -1
  21. package/dist/useAgUiRuntime.js.map +1 -1
  22. package/package.json +4 -4
  23. package/src/runtime/AgUiThreadRuntimeCore.ts +21 -1
  24. package/src/runtime/adapter/conversions.content.test.ts +220 -0
  25. package/src/runtime/adapter/conversions.ts +215 -11
  26. package/src/runtime/adapter/run-aggregator.ts +59 -4
  27. package/src/runtime/adapter/subscriber.ts +3 -0
  28. package/src/runtime/event-parser.ts +13 -0
  29. package/src/runtime/types.ts +6 -0
  30. package/src/useAgUiRuntime.queue.test.tsx +68 -1
  31. package/src/useAgUiRuntime.ts +1 -1
@@ -0,0 +1,220 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { UserMessageSchema } from "@ag-ui/client";
3
+ import { fromAgUiMessages, toAgUiMessages } from "./conversions";
4
+
5
+ type Message = Parameters<typeof toAgUiMessages>[0][number];
6
+
7
+ const userMessage = (
8
+ content: unknown,
9
+ attachments?: Message["attachments"],
10
+ ): Message =>
11
+ ({
12
+ id: "u1",
13
+ role: "user",
14
+ content,
15
+ ...(attachments && { attachments }),
16
+ }) as Message;
17
+
18
+ const contentOf = (message: Message) => {
19
+ const [converted] = toAgUiMessages([message]);
20
+ return (converted as { content: unknown }).content;
21
+ };
22
+
23
+ describe("toAgUiMessages content metadata", () => {
24
+ it("carries a part's agui provider metadata onto an image item", () => {
25
+ expect(
26
+ contentOf(
27
+ userMessage([
28
+ {
29
+ type: "image",
30
+ image: "https://example.com/cat.png",
31
+ providerMetadata: { agui: { file_id: "f_1" } },
32
+ },
33
+ ]),
34
+ ),
35
+ ).toEqual([
36
+ {
37
+ type: "image",
38
+ source: { type: "url", value: "https://example.com/cat.png" },
39
+ metadata: { file_id: "f_1" },
40
+ },
41
+ ]);
42
+ });
43
+
44
+ it("preserves a direct image part's filename through a snapshot round trip", () => {
45
+ const sent = toAgUiMessages([
46
+ userMessage([
47
+ {
48
+ type: "image",
49
+ image: "https://example.com/cat.png",
50
+ filename: "cat.png",
51
+ },
52
+ ]),
53
+ ]);
54
+
55
+ expect((sent[0] as { content: unknown }).content).toEqual([
56
+ {
57
+ type: "image",
58
+ source: { type: "url", value: "https://example.com/cat.png" },
59
+ metadata: { filename: "cat.png" },
60
+ },
61
+ ]);
62
+
63
+ const rebuilt = fromAgUiMessages(sent as never);
64
+ const attachment = (rebuilt[0] as unknown as { attachments: unknown[] })
65
+ .attachments[0];
66
+
67
+ expect(attachment).toMatchObject({
68
+ name: "cat.png",
69
+ content: [{ type: "image", filename: "cat.png" }],
70
+ });
71
+ });
72
+
73
+ it("merges a file part's metadata with its filename", () => {
74
+ expect(
75
+ contentOf(
76
+ userMessage([
77
+ {
78
+ type: "file",
79
+ data: "https://example.com/spec.pdf",
80
+ mimeType: "application/pdf",
81
+ filename: "spec.pdf",
82
+ providerMetadata: { agui: { file_id: "f_2" } },
83
+ },
84
+ ]),
85
+ ),
86
+ ).toEqual([
87
+ {
88
+ type: "document",
89
+ source: {
90
+ type: "url",
91
+ value: "https://example.com/spec.pdf",
92
+ mimeType: "application/pdf",
93
+ },
94
+ metadata: { file_id: "f_2", filename: "spec.pdf" },
95
+ },
96
+ ]);
97
+ });
98
+
99
+ it("keeps the part's own filename over one in the metadata", () => {
100
+ expect(
101
+ contentOf(
102
+ userMessage([
103
+ {
104
+ type: "file",
105
+ data: "https://example.com/spec.pdf",
106
+ mimeType: "application/pdf",
107
+ filename: "spec.pdf",
108
+ providerMetadata: { agui: { filename: "renamed.pdf" } },
109
+ },
110
+ ]),
111
+ ),
112
+ ).toMatchObject([{ metadata: { filename: "spec.pdf" } }]);
113
+ });
114
+
115
+ it("carries metadata from an attachment's content part", () => {
116
+ expect(
117
+ contentOf(
118
+ userMessage([], [
119
+ {
120
+ name: "spec.pdf",
121
+ contentType: "application/pdf",
122
+ content: [
123
+ {
124
+ type: "file",
125
+ data: "https://example.com/spec.pdf",
126
+ mimeType: "application/pdf",
127
+ providerMetadata: { agui: { file_id: "f_3" } },
128
+ },
129
+ ],
130
+ },
131
+ ] as Message["attachments"]),
132
+ ),
133
+ ).toMatchObject([{ type: "document", metadata: { file_id: "f_3" } }]);
134
+ });
135
+
136
+ it("ignores metadata namespaced for another provider", () => {
137
+ expect(
138
+ contentOf(
139
+ userMessage([
140
+ {
141
+ type: "image",
142
+ image: "https://example.com/cat.png",
143
+ providerMetadata: { openai: { file_id: "f_4" } },
144
+ },
145
+ ]),
146
+ ),
147
+ ).toEqual([
148
+ {
149
+ type: "image",
150
+ source: { type: "url", value: "https://example.com/cat.png" },
151
+ },
152
+ ]);
153
+ });
154
+
155
+ it("emits no metadata channel for text, which the schema has no field for", () => {
156
+ expect(
157
+ contentOf(
158
+ userMessage([
159
+ {
160
+ type: "text",
161
+ text: "hello",
162
+ providerMetadata: { agui: { file_id: "f_5" } },
163
+ },
164
+ { type: "image", image: "https://example.com/cat.png" },
165
+ ]),
166
+ ),
167
+ ).toEqual([
168
+ { type: "text", text: "hello" },
169
+ {
170
+ type: "image",
171
+ source: { type: "url", value: "https://example.com/cat.png" },
172
+ },
173
+ ]);
174
+ });
175
+
176
+ it("stays valid against the upstream user message schema", () => {
177
+ const [converted] = toAgUiMessages([
178
+ userMessage([
179
+ {
180
+ type: "file",
181
+ data: "https://example.com/spec.pdf",
182
+ mimeType: "application/pdf",
183
+ filename: "spec.pdf",
184
+ providerMetadata: { agui: { file_id: "f_6", nested: { a: 1 } } },
185
+ },
186
+ ]),
187
+ ]);
188
+
189
+ expect(
190
+ UserMessageSchema.safeParse(JSON.parse(JSON.stringify(converted)))
191
+ .success,
192
+ ).toBe(true);
193
+ });
194
+
195
+ it("survives a snapshot round trip", () => {
196
+ const sent = toAgUiMessages([
197
+ userMessage([
198
+ {
199
+ type: "file",
200
+ data: "https://example.com/spec.pdf",
201
+ mimeType: "application/pdf",
202
+ filename: "spec.pdf",
203
+ providerMetadata: { agui: { file_id: "f_7" } },
204
+ },
205
+ ]),
206
+ ]);
207
+
208
+ const rebuilt = fromAgUiMessages(sent as never);
209
+ const attachment = (rebuilt[0] as unknown as { attachments: unknown[] })
210
+ .attachments[0] as { content: unknown[] };
211
+
212
+ expect(attachment.content[0]).toMatchObject({
213
+ type: "file",
214
+ filename: "spec.pdf",
215
+ providerMetadata: { agui: { file_id: "f_7" } },
216
+ });
217
+
218
+ expect(toAgUiMessages(rebuilt as never)).toEqual(sent);
219
+ });
220
+ });
@@ -3,6 +3,7 @@
3
3
  import type { InputContent, RunAgentParameters } from "@ag-ui/client";
4
4
  import type {
5
5
  ThreadMessageLike as CoreThreadMessageLike,
6
+ PartProviderMetadata,
6
7
  ToolCallMessagePartMcpMetadata,
7
8
  ToolModelContentPart,
8
9
  } from "@assistant-ui/core";
@@ -17,6 +18,7 @@ import {
17
18
  AG_UI_METADATA_NAMESPACE,
18
19
  A2UI_SURFACE_ACTIVITY_TYPE,
19
20
  type AgUiCustomMetadata,
21
+ type AgUiOpaqueReasoning,
20
22
  } from "./run-aggregator";
21
23
  import {
22
24
  applyA2uiOperations,
@@ -42,6 +44,7 @@ type ThreadMessageLike = {
42
44
  id: string;
43
45
  role: string;
44
46
  content: unknown;
47
+ metadata?: unknown;
45
48
  name?: string;
46
49
  toolCallId?: string;
47
50
  error?: string;
@@ -62,6 +65,12 @@ export type AgUiMessage =
62
65
  name?: string;
63
66
  toolCalls?: AgUiToolCall[];
64
67
  }
68
+ | {
69
+ id: string;
70
+ role: "reasoning";
71
+ content: string;
72
+ encryptedValue?: string;
73
+ }
65
74
  | {
66
75
  id: string;
67
76
  role: "tool";
@@ -94,6 +103,56 @@ const getString = (record: Record<string, unknown>, key: string) => {
94
103
  const getToolCallId = (record: Record<string, unknown>) =>
95
104
  getString(record, "toolCallId") ?? getString(record, "tool_call_id");
96
105
 
106
+ const readAgUiNamespace = (providerMetadata: unknown) => {
107
+ if (!isObject(providerMetadata)) return undefined;
108
+ const namespaced = providerMetadata[AG_UI_METADATA_NAMESPACE];
109
+ return isObject(namespaced) ? namespaced : undefined;
110
+ };
111
+
112
+ const readAgUiReasoningMeta = (providerMetadata: unknown) => {
113
+ const namespaced = readAgUiNamespace(providerMetadata);
114
+ if (!namespaced) return {};
115
+ return {
116
+ encryptedValue: getString(namespaced, "encryptedValue"),
117
+ reasoningId: getString(namespaced, "reasoningId"),
118
+ };
119
+ };
120
+
121
+ // AG-UI carries per-item extras in `metadata`; anything else on the item is
122
+ // stripped by its schema. The part's own `filename` wins over a key of the
123
+ // same name in the bag.
124
+ const buildInputMetadata = (
125
+ part: Record<string, unknown>,
126
+ filename?: string | undefined,
127
+ ) => {
128
+ const metadata = {
129
+ ...readAgUiNamespace(part.providerMetadata),
130
+ ...(filename !== undefined && { filename }),
131
+ };
132
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
133
+ };
134
+
135
+ // The inverse: `filename` lands on the part's own field, everything else the
136
+ // item carried goes back into the namespace it came from, so a snapshot echo
137
+ // resends what the host attached.
138
+ const readInputMetadata = (
139
+ metadata: unknown,
140
+ ): {
141
+ filename?: string | undefined;
142
+ providerMetadata?: PartProviderMetadata | undefined;
143
+ } => {
144
+ if (!isObject(metadata)) return {};
145
+ const { filename: _filename, ...rest } = metadata;
146
+ return {
147
+ filename: getString(metadata, "filename"),
148
+ ...(Object.keys(rest).length > 0 && {
149
+ providerMetadata: {
150
+ [AG_UI_METADATA_NAMESPACE]: rest as PartProviderMetadata[string],
151
+ },
152
+ }),
153
+ };
154
+ };
155
+
97
156
  function parseJSONText(value: string): unknown {
98
157
  if (!value) return value;
99
158
  try {
@@ -198,7 +257,12 @@ function toInputContent(
198
257
  if (type === "image") {
199
258
  const image = getString(part, "image");
200
259
  if (image === undefined) return null;
201
- return { type: "image", source: buildInputSource(image, fallbackMimeType) };
260
+ const metadata = buildInputMetadata(part, getString(part, "filename"));
261
+ return {
262
+ type: "image",
263
+ source: buildInputSource(image, fallbackMimeType),
264
+ ...(metadata && { metadata }),
265
+ };
202
266
  }
203
267
 
204
268
  if (type === "file") {
@@ -211,7 +275,7 @@ function toInputContent(
211
275
  declaredMimeType,
212
276
  getString(part, "sourceType"),
213
277
  );
214
- const metadata = filename !== undefined ? { filename } : undefined;
278
+ const metadata = buildInputMetadata(part, filename);
215
279
  switch (mediaTypeForMime(source.mimeType)) {
216
280
  case "image":
217
281
  return { type: "image", source, ...(metadata && { metadata }) };
@@ -312,9 +376,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
312
376
  const source = inputSourceToString(part.source);
313
377
  if (!source) continue;
314
378
 
315
- const filename = isObject(part.metadata)
316
- ? getString(part.metadata, "filename")
317
- : undefined;
379
+ const { filename, providerMetadata } = readInputMetadata(part.metadata);
318
380
  const id = attachments.length.toString();
319
381
 
320
382
  if (type === "image") {
@@ -329,6 +391,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
329
391
  type: "image",
330
392
  image: source.value,
331
393
  ...(filename !== undefined && { filename }),
394
+ ...(providerMetadata && { providerMetadata }),
332
395
  },
333
396
  ],
334
397
  });
@@ -349,6 +412,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
349
412
  mimeType,
350
413
  ...(source.isUrl && { sourceType: "url" as const }),
351
414
  ...(filename !== undefined && { filename }),
415
+ ...(providerMetadata && { providerMetadata }),
352
416
  },
353
417
  ],
354
418
  });
@@ -498,6 +562,46 @@ function readPersistedInterrupts(
498
562
  return validateInterrupts(namespaced.interrupts);
499
563
  }
500
564
 
565
+ function readOpaqueReasoning(metadata: unknown): AgUiOpaqueReasoning[] {
566
+ if (!isObject(metadata)) return [];
567
+ const custom = metadata.custom;
568
+ if (!isObject(custom)) return [];
569
+ const namespaced = custom[AG_UI_METADATA_NAMESPACE];
570
+ if (!isObject(namespaced)) return [];
571
+ const entries = namespaced.opaqueReasoning;
572
+ if (!Array.isArray(entries)) return [];
573
+ return entries.flatMap((entry) => {
574
+ if (!isObject(entry)) return [];
575
+ const id = getString(entry, "id");
576
+ const encryptedValue = getString(entry, "encryptedValue");
577
+ if (!id?.trim() || !encryptedValue?.trim()) return [];
578
+ return [
579
+ { id, encryptedValue, ...(entry.after === true ? { after: true } : {}) },
580
+ ];
581
+ });
582
+ }
583
+
584
+ function withOpaqueReasoning(
585
+ message: CoreThreadMessageLike,
586
+ opaqueReasoning: AgUiOpaqueReasoning[],
587
+ ): CoreThreadMessageLike {
588
+ const metadata = isObject(message.metadata) ? message.metadata : {};
589
+ const custom = isObject(metadata.custom) ? metadata.custom : {};
590
+ const namespaced = isObject(custom[AG_UI_METADATA_NAMESPACE])
591
+ ? (custom[AG_UI_METADATA_NAMESPACE] as Record<string, unknown>)
592
+ : {};
593
+ return {
594
+ ...message,
595
+ metadata: {
596
+ ...metadata,
597
+ custom: {
598
+ ...custom,
599
+ [AG_UI_METADATA_NAMESPACE]: { ...namespaced, opaqueReasoning },
600
+ },
601
+ },
602
+ } as CoreThreadMessageLike;
603
+ }
604
+
501
605
  function toAssistantSnapshotMessage(
502
606
  rawMessage: Record<string, unknown>,
503
607
  ): CoreThreadMessageLike {
@@ -596,6 +700,11 @@ export function fromAgUiMessages(
596
700
  const converted: CoreThreadMessageLike[] = [];
597
701
  const a2uiBuckets = new Map<string, A2uiState>();
598
702
  const a2uiBucketOwnerIndices = new Map<string, number>();
703
+ // A zero-data-retention run carries its payload in encryptedValue with no
704
+ // readable content, so the record has no part to become and rides on the
705
+ // neighbouring message instead of being dropped. The anchor is the index the
706
+ // next pushed message will occupy, which is where it sat on the wire.
707
+ const opaqueReasoning: (AgUiOpaqueReasoning & { anchor: number })[] = [];
599
708
 
600
709
  for (const rawMessage of messages) {
601
710
  if (!isObject(rawMessage)) continue;
@@ -750,15 +859,53 @@ export function fromAgUiMessages(
750
859
  }
751
860
 
752
861
  if (role === "reasoning") {
753
- // Gate on showThinking so a cold reload matches the live run: the
754
- // aggregator never stores reasoning parts when showThinking is false.
755
- if (!showThinking) continue;
756
862
  const text = extractText(rawMessage.content);
757
- if (text.trim().length === 0) continue;
863
+ const encryptedValue = getString(rawMessage, "encryptedValue");
864
+ if (text.trim().length === 0) {
865
+ // showThinking hides reasoning; this record is never rendered anyway,
866
+ // and dropping it would deny the provider the payload it needs to
867
+ // accept the next run.
868
+ const opaqueId = getString(rawMessage, "id");
869
+ if (opaqueId?.trim() && encryptedValue?.trim()) {
870
+ opaqueReasoning.push({
871
+ id: opaqueId,
872
+ encryptedValue,
873
+ anchor: converted.length,
874
+ });
875
+ }
876
+ continue;
877
+ }
878
+ // Gate visible reasoning on showThinking so a cold reload matches the
879
+ // live run: the aggregator never stores reasoning parts when it is off.
880
+ // A signature on a hidden record is still transport state, so it is kept
881
+ // opaque rather than discarded with the text.
882
+ if (!showThinking) {
883
+ const hiddenId = getString(rawMessage, "id");
884
+ if (hiddenId?.trim() && encryptedValue?.trim()) {
885
+ opaqueReasoning.push({
886
+ id: hiddenId,
887
+ encryptedValue,
888
+ anchor: converted.length,
889
+ });
890
+ }
891
+ continue;
892
+ }
758
893
  converted.push({
759
894
  id: getString(rawMessage, "id") ?? generateId(),
760
895
  role: "assistant",
761
- content: [{ type: "reasoning", text }],
896
+ content: [
897
+ {
898
+ type: "reasoning",
899
+ text,
900
+ ...(encryptedValue !== undefined
901
+ ? {
902
+ providerMetadata: {
903
+ [AG_UI_METADATA_NAMESPACE]: { encryptedValue },
904
+ },
905
+ }
906
+ : {}),
907
+ },
908
+ ],
762
909
  });
763
910
  continue;
764
911
  }
@@ -768,6 +915,18 @@ export function fromAgUiMessages(
768
915
  }
769
916
  }
770
917
 
918
+ for (const { anchor, ...entry } of opaqueReasoning) {
919
+ // Nothing followed it on the wire, so it trails the last message instead.
920
+ const trailing = anchor >= converted.length;
921
+ const index = trailing ? converted.length - 1 : anchor;
922
+ const target = converted[index];
923
+ if (!target) continue;
924
+ converted[index] = withOpaqueReasoning(target, [
925
+ ...readOpaqueReasoning(target.metadata),
926
+ ...(trailing ? [{ ...entry, after: true }] : [entry]),
927
+ ]);
928
+ }
929
+
771
930
  for (let i = 0; i < converted.length; i++) {
772
931
  const message = converted[i]!;
773
932
  if (message.role !== "assistant") continue;
@@ -821,9 +980,35 @@ function convertAssistantMessage(
821
980
  part,
822
981
  }));
823
982
 
983
+ // An AG-UI assistant record has no reasoning field, so reasoning leaves as
984
+ // the standalone record it arrived as, ahead of the assistant it belongs to.
985
+ const shellIsDropped = content.length === 0 && toolCalls.length === 0;
986
+ let reasoningIndex = 0;
987
+ for (const part of contentArray) {
988
+ if (!isObject(part) || part.type !== "reasoning") continue;
989
+ const text = getString(part, "text") ?? "";
990
+ if (text.trim().length === 0) continue;
991
+ const { encryptedValue, reasoningId } = readAgUiReasoningMeta(
992
+ part.providerMetadata,
993
+ );
994
+ converted.push({
995
+ // The wire id is what a signature was issued against, so it wins over a
996
+ // synthesized one whenever the run carried it.
997
+ id: reasoningId?.trim()
998
+ ? reasoningId
999
+ : shellIsDropped && reasoningIndex === 0
1000
+ ? message.id
1001
+ : `${message.id}:reasoning-${reasoningIndex}`,
1002
+ role: "reasoning",
1003
+ content: text,
1004
+ ...(encryptedValue !== undefined ? { encryptedValue } : {}),
1005
+ });
1006
+ reasoningIndex++;
1007
+ }
1008
+
824
1009
  // Drop assistant messages with no text or tool calls (e.g. an imported
825
1010
  // reasoning-only entry) so they are not re-sent as a blank assistant turn.
826
- if (content.length === 0 && toolCalls.length === 0) {
1011
+ if (shellIsDropped) {
827
1012
  return;
828
1013
  }
829
1014
 
@@ -888,13 +1073,31 @@ export function toAgUiMessages(
888
1073
  const converted: AgUiMessage[] = [];
889
1074
 
890
1075
  for (const message of messages) {
1076
+ const opaqueReasoning = readOpaqueReasoning(message.metadata);
1077
+ const toOpaqueRecord = (entry: AgUiOpaqueReasoning): AgUiMessage => ({
1078
+ id: entry.id,
1079
+ role: "reasoning",
1080
+ content: "",
1081
+ encryptedValue: entry.encryptedValue,
1082
+ });
1083
+ for (const entry of opaqueReasoning) {
1084
+ if (entry.after !== true) converted.push(toOpaqueRecord(entry));
1085
+ }
1086
+ const flushTrailingOpaqueReasoning = () => {
1087
+ for (const entry of opaqueReasoning) {
1088
+ if (entry.after === true) converted.push(toOpaqueRecord(entry));
1089
+ }
1090
+ };
1091
+
891
1092
  if (message.role === "assistant") {
892
1093
  convertAssistantMessage(message, converted);
1094
+ flushTrailingOpaqueReasoning();
893
1095
  continue;
894
1096
  }
895
1097
 
896
1098
  if (message.role === "tool") {
897
1099
  convertToolMessage(message, converted);
1100
+ flushTrailingOpaqueReasoning();
898
1101
  continue;
899
1102
  }
900
1103
 
@@ -910,6 +1113,7 @@ export function toAgUiMessages(
910
1113
  genericMessage.name = message.name;
911
1114
  }
912
1115
  converted.push(genericMessage);
1116
+ flushTrailingOpaqueReasoning();
913
1117
  }
914
1118
 
915
1119
  return converted;
@@ -20,8 +20,15 @@ import type { Logger } from "../logger";
20
20
 
21
21
  export const AG_UI_METADATA_NAMESPACE = "agui";
22
22
 
23
+ export type AgUiOpaqueReasoning = {
24
+ id: string;
25
+ encryptedValue: string;
26
+ after?: boolean;
27
+ };
28
+
23
29
  export type AgUiCustomMetadata = {
24
30
  interrupts?: AgUiInterrupt[];
31
+ opaqueReasoning?: AgUiOpaqueReasoning[];
25
32
  };
26
33
 
27
34
  type Emit = (update: ChatModelRunResult) => void;
@@ -77,6 +84,9 @@ export class RunAggregator {
77
84
  >();
78
85
  private activeTextMessageId: string | undefined;
79
86
  private readonly reasoningParts = new Map<string, string>(); // key → buffer
87
+ private readonly reasoningSignatures = new Map<string, string>();
88
+ private readonly reasoningMessageIds = new Map<string, string>();
89
+ private readonly anonymousReasoningKeys = new Set<string>();
80
90
  private activeReasoningKey: string | undefined;
81
91
  private reasoningPartCounter = 0;
82
92
  private readonly toolCalls = new Map<string, ToolCallState>();
@@ -111,6 +121,9 @@ export class RunAggregator {
111
121
  case "RUN_STARTED": {
112
122
  this.clearTextParts();
113
123
  this.reasoningParts.clear();
124
+ this.reasoningSignatures.clear();
125
+ this.reasoningMessageIds.clear();
126
+ this.anonymousReasoningKeys.clear();
114
127
  this.activeReasoningKey = undefined;
115
128
  this.reasoningPartCounter = 0;
116
129
  this.toolCalls.clear();
@@ -200,8 +213,25 @@ export class RunAggregator {
200
213
  case "REASONING_MESSAGE_START":
201
214
  this.handleReasoningStart(
202
215
  "messageId" in event ? event.messageId : undefined,
216
+ event.type === "REASONING_MESSAGE_START",
203
217
  );
204
218
  break;
219
+ case "REASONING_ENCRYPTED_VALUE":
220
+ if (event.subtype === "message") {
221
+ // entityId names any message, not necessarily a reasoning one, so an
222
+ // unmatched id may only claim a block that has no id to contradict it.
223
+ const active = this.activeReasoningKey;
224
+ const key = this.reasoningParts.has(event.entityId)
225
+ ? event.entityId
226
+ : active !== undefined && this.anonymousReasoningKeys.has(active)
227
+ ? active
228
+ : undefined;
229
+ if (key !== undefined) {
230
+ this.reasoningSignatures.set(key, event.encryptedValue);
231
+ this.emit();
232
+ }
233
+ }
234
+ break;
205
235
  case "THINKING_TEXT_MESSAGE_CONTENT":
206
236
  this.handleReasoningContent(event.delta);
207
237
  this.totalChunks++;
@@ -211,6 +241,7 @@ export class RunAggregator {
211
241
  this.handleReasoningContent(
212
242
  event.delta,
213
243
  "messageId" in event ? event.messageId : undefined,
244
+ true,
214
245
  );
215
246
  this.totalChunks++;
216
247
  this.recordFirstToken();
@@ -589,7 +620,19 @@ export class RunAggregator {
589
620
  if (this.showThinking) {
590
621
  const buffer = this.reasoningParts.get(part.key) ?? "";
591
622
  if (buffer.length > 0 || this.activeReasoningKey === part.key) {
592
- snapshot.push({ type: "reasoning", text: buffer } as const);
623
+ const encryptedValue = this.reasoningSignatures.get(part.key);
624
+ const reasoningId = this.reasoningMessageIds.get(part.key);
625
+ const meta = {
626
+ ...(reasoningId !== undefined ? { reasoningId } : {}),
627
+ ...(encryptedValue !== undefined ? { encryptedValue } : {}),
628
+ };
629
+ snapshot.push({
630
+ type: "reasoning",
631
+ text: buffer,
632
+ ...(Object.keys(meta).length > 0
633
+ ? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
634
+ : {}),
635
+ } as const);
593
636
  }
594
637
  }
595
638
  continue;
@@ -698,12 +741,20 @@ export class RunAggregator {
698
741
  };
699
742
  }
700
743
 
701
- private handleReasoningStart(messageId?: string): void {
744
+ private handleReasoningStart(messageId?: string, isMessageId = false): void {
702
745
  if (!this.showThinking) return;
703
746
  // A reasoning block acts as a boundary: anonymous text arriving after it
704
747
  // should be a new part, not appended to any pre-reasoning text.
705
748
  this.activeTextMessageId = undefined;
706
749
  const key = messageId ?? `__auto-reasoning-${++this.reasoningPartCounter}`;
750
+ // Two different questions: which id may be replayed as a ReasoningMessage.id
751
+ // (only the message-scoped aliases carry one), and which block an unmatched
752
+ // signature may claim (any block opened without an id at all).
753
+ if (messageId === undefined) {
754
+ this.anonymousReasoningKeys.add(key);
755
+ } else if (isMessageId) {
756
+ this.reasoningMessageIds.set(key, messageId);
757
+ }
707
758
  if (!this.reasoningParts.has(key)) {
708
759
  this.reasoningParts.set(key, "");
709
760
  this.partOrder.push({ kind: "reasoning", key });
@@ -712,11 +763,15 @@ export class RunAggregator {
712
763
  this.emit();
713
764
  }
714
765
 
715
- private handleReasoningContent(delta: string, messageId?: string): void {
766
+ private handleReasoningContent(
767
+ delta: string,
768
+ messageId?: string,
769
+ isMessageId = false,
770
+ ): void {
716
771
  if (!this.showThinking || !delta) return;
717
772
  if (!this.activeReasoningKey) {
718
773
  // Content arrived without a preceding START — create the slot lazily.
719
- this.handleReasoningStart(messageId);
774
+ this.handleReasoningStart(messageId, isMessageId);
720
775
  }
721
776
  const key = this.activeReasoningKey;
722
777
  if (!key) return;