@assistant-ui/react-ag-ui 0.1.1 → 0.1.2
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/README.md +2 -0
- package/dist/runtime/AgUiThreadController.d.ts +11 -0
- package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadController.js +212 -22
- package/dist/runtime/AgUiThreadController.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +9 -1
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +184 -9
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +34 -12
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +0 -1
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +1 -8
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +1 -0
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/types.d.ts +2 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js +2 -1
- package/dist/runtime/types.js.map +1 -1
- package/package.json +5 -5
- package/src/runtime/AgUiThreadController.disconnect.test.ts +914 -0
- package/src/runtime/AgUiThreadController.interrupt-history.test.ts +392 -0
- package/src/runtime/AgUiThreadController.replay.test.ts +8 -1
- package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +167 -0
- package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
- package/src/runtime/AgUiThreadController.test.ts +40 -0
- package/src/runtime/AgUiThreadController.ts +459 -27
- package/src/runtime/adapter/conversions.content.test.ts +129 -0
- package/src/runtime/adapter/conversions.subagent-messages.test.ts +550 -0
- package/src/runtime/adapter/conversions.ts +331 -20
- package/src/runtime/adapter/run-aggregator.metadata.test.ts +151 -0
- package/src/runtime/adapter/run-aggregator.ts +52 -9
- package/src/runtime/adapter/subscriber.test.ts +1 -1
- package/src/runtime/adapter/subscriber.ts +1 -6
- package/src/runtime/event-parser.ts +3 -0
- package/src/runtime/types.ts +5 -0
|
@@ -5,8 +5,10 @@ import type {
|
|
|
5
5
|
PartSource,
|
|
6
6
|
RunAgentParameters,
|
|
7
7
|
} from "@ag-ui/client";
|
|
8
|
-
import { generateId } from "@assistant-ui/core";
|
|
8
|
+
import { fromThreadMessageLike, generateId } from "@assistant-ui/core";
|
|
9
9
|
import type {
|
|
10
|
+
MessageStatus,
|
|
11
|
+
ThreadMessage,
|
|
10
12
|
ThreadMessageLike as CoreThreadMessageLike,
|
|
11
13
|
PartProviderMetadata,
|
|
12
14
|
ReasoningMessagePart,
|
|
@@ -26,6 +28,7 @@ import {
|
|
|
26
28
|
MCP_APPS_ACTIVITY_TYPE,
|
|
27
29
|
type AgUiCustomMetadata,
|
|
28
30
|
type AgUiOpaqueReasoning,
|
|
31
|
+
MAX_SUBAGENT_DEPTH,
|
|
29
32
|
} from "../types";
|
|
30
33
|
import {
|
|
31
34
|
applyA2uiOperations,
|
|
@@ -212,7 +215,7 @@ function withAgUiMessageMetadata(metadata: unknown): {
|
|
|
212
215
|
return messageMetadata !== undefined ? { metadata: messageMetadata } : {};
|
|
213
216
|
}
|
|
214
217
|
|
|
215
|
-
function withAgUiPartMessageMetadata(
|
|
218
|
+
export function withAgUiPartMessageMetadata(
|
|
216
219
|
providerMetadata: PartProviderMetadata | undefined,
|
|
217
220
|
messageMetadata: Record<string, unknown> | undefined,
|
|
218
221
|
): PartProviderMetadata {
|
|
@@ -222,11 +225,17 @@ function withAgUiPartMessageMetadata(
|
|
|
222
225
|
const namespaced = isObject(existing[AG_UI_METADATA_NAMESPACE])
|
|
223
226
|
? existing[AG_UI_METADATA_NAMESPACE]
|
|
224
227
|
: {};
|
|
228
|
+
const previousMessageMetadata = isObject(namespaced.messageMetadata)
|
|
229
|
+
? namespaced.messageMetadata
|
|
230
|
+
: {};
|
|
225
231
|
return {
|
|
226
232
|
...existing,
|
|
227
233
|
[AG_UI_METADATA_NAMESPACE]: {
|
|
228
234
|
...namespaced,
|
|
229
|
-
messageMetadata:
|
|
235
|
+
messageMetadata: {
|
|
236
|
+
...previousMessageMetadata,
|
|
237
|
+
...messageMetadata,
|
|
238
|
+
} as ReadonlyJSONObject,
|
|
230
239
|
},
|
|
231
240
|
};
|
|
232
241
|
}
|
|
@@ -342,8 +351,6 @@ function extractText(content: unknown): string {
|
|
|
342
351
|
.join("\n");
|
|
343
352
|
}
|
|
344
353
|
|
|
345
|
-
type EncodedPartSource = Extract<PartSource, { type: "data" | "url" }>;
|
|
346
|
-
|
|
347
354
|
type MediaInputType = "image" | "audio" | "video" | "document";
|
|
348
355
|
|
|
349
356
|
function mediaTypeForMime(mimeType: string | undefined): MediaInputType {
|
|
@@ -363,7 +370,14 @@ function buildPartSource(
|
|
|
363
370
|
value: string,
|
|
364
371
|
declaredMimeType: string | undefined,
|
|
365
372
|
sourceType?: string,
|
|
366
|
-
):
|
|
373
|
+
): PartSource {
|
|
374
|
+
if (sourceType === "id") {
|
|
375
|
+
return {
|
|
376
|
+
type: "file",
|
|
377
|
+
value,
|
|
378
|
+
...(declaredMimeType !== undefined && { mimeType: declaredMimeType }),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
367
381
|
const source = resolveFilePartSource({
|
|
368
382
|
data: value,
|
|
369
383
|
mimeType: declaredMimeType ?? "application/octet-stream",
|
|
@@ -460,9 +474,12 @@ type SnapshotAttachment = NonNullable<
|
|
|
460
474
|
|
|
461
475
|
const mediaInputTypes = new Set(["image", "audio", "video", "document"]);
|
|
462
476
|
|
|
463
|
-
function partSourceToString(
|
|
464
|
-
value:
|
|
465
|
-
|
|
477
|
+
function partSourceToString(value: unknown): {
|
|
478
|
+
value: string;
|
|
479
|
+
mimeType?: string;
|
|
480
|
+
isUrl?: boolean;
|
|
481
|
+
isId?: boolean;
|
|
482
|
+
} | null {
|
|
466
483
|
if (!isObject(value)) return null;
|
|
467
484
|
const sourceValue = getString(value, "value");
|
|
468
485
|
if (sourceValue === undefined) return null;
|
|
@@ -475,6 +492,13 @@ function partSourceToString(
|
|
|
475
492
|
...(mimeType !== undefined && { mimeType }),
|
|
476
493
|
};
|
|
477
494
|
}
|
|
495
|
+
if (type === "file") {
|
|
496
|
+
return {
|
|
497
|
+
value: sourceValue,
|
|
498
|
+
isId: true,
|
|
499
|
+
...(mimeType !== undefined && { mimeType }),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
478
502
|
if (type === "data") {
|
|
479
503
|
const resolvedMimeType = mimeType ?? "application/octet-stream";
|
|
480
504
|
return {
|
|
@@ -541,15 +565,11 @@ function toUserContentLayout(content: unknown): {
|
|
|
541
565
|
}
|
|
542
566
|
const part = normalizeInputPart(rawPart);
|
|
543
567
|
if (!part || !mediaInputTypes.has(getString(part, "type") ?? "")) continue;
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
typeof part.source.value === "string"
|
|
548
|
-
) {
|
|
549
|
-
fileSourceParts.push({ index: mediaIndex++, part });
|
|
550
|
-
} else if (partSourceToString(part.source)) {
|
|
551
|
-
mediaIndex++;
|
|
568
|
+
if (!partSourceToString(part.source)) continue;
|
|
569
|
+
if (isObject(part.source) && part.source.type === "file") {
|
|
570
|
+
fileSourceParts.push({ index: mediaIndex, part });
|
|
552
571
|
}
|
|
572
|
+
mediaIndex++;
|
|
553
573
|
}
|
|
554
574
|
|
|
555
575
|
const leadingText =
|
|
@@ -576,7 +596,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
|
|
|
576
596
|
const { filename, providerMetadata } = readPartMetadata(part.metadata);
|
|
577
597
|
const id = attachments.length.toString();
|
|
578
598
|
|
|
579
|
-
if (type === "image") {
|
|
599
|
+
if (type === "image" && !source.isId) {
|
|
580
600
|
attachments.push({
|
|
581
601
|
id,
|
|
582
602
|
type: "image",
|
|
@@ -608,6 +628,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
|
|
|
608
628
|
data: source.value,
|
|
609
629
|
mimeType,
|
|
610
630
|
...(source.isUrl && { sourceType: "url" as const }),
|
|
631
|
+
...(source.isId && { sourceType: "id" as const }),
|
|
611
632
|
...(filename !== undefined && { filename }),
|
|
612
633
|
...(providerMetadata && { providerMetadata }),
|
|
613
634
|
},
|
|
@@ -679,15 +700,28 @@ function buildUserContent(message: ThreadMessageLike): string | ContentPart[] {
|
|
|
679
700
|
isObject(part.source) &&
|
|
680
701
|
part.source.type === "file" &&
|
|
681
702
|
typeof part.source.value === "string"
|
|
682
|
-
? [{ index, part: part as ContentPart }]
|
|
703
|
+
? [{ index, part: part as ContentPart, value: part.source.value }]
|
|
683
704
|
: [],
|
|
684
705
|
);
|
|
685
706
|
const ordered = restoresText
|
|
686
707
|
? converted.filter((part) => part.type !== "text")
|
|
687
708
|
: converted;
|
|
688
|
-
for (const { index, part } of fileSourceSlots.sort(
|
|
709
|
+
for (const { index, part, value } of fileSourceSlots.sort(
|
|
689
710
|
(a, b) => a.index - b.index,
|
|
690
711
|
)) {
|
|
712
|
+
// A snapshot also imports the handle as an attachment; the slot replaces
|
|
713
|
+
// it in place to restore fields the attachment cannot hold.
|
|
714
|
+
const attached = ordered.findIndex(
|
|
715
|
+
(contentPart) =>
|
|
716
|
+
contentPart.type !== "text" &&
|
|
717
|
+
contentPart.source.type === "file" &&
|
|
718
|
+
contentPart.source.value === value &&
|
|
719
|
+
!fileSourceSlots.some((slot) => slot.part === contentPart),
|
|
720
|
+
);
|
|
721
|
+
if (attached !== -1) {
|
|
722
|
+
ordered[attached] = part;
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
691
725
|
const mediaPositions = ordered.flatMap((contentPart, position) =>
|
|
692
726
|
contentPart.type !== "text" ? [position] : [],
|
|
693
727
|
);
|
|
@@ -1102,9 +1136,286 @@ export type FromAgUiMessagesOptions = {
|
|
|
1102
1136
|
showThinking?: boolean;
|
|
1103
1137
|
};
|
|
1104
1138
|
|
|
1139
|
+
const readSubagentRunId = (record: Record<string, unknown>) => {
|
|
1140
|
+
const runId = getString(record, "subagentRunId");
|
|
1141
|
+
return runId?.trim() ? runId : undefined;
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
const readToolCallIds = (record: Record<string, unknown>) =>
|
|
1145
|
+
extractAssistantToolCalls(record).flatMap((part) =>
|
|
1146
|
+
part.toolCallId ? [part.toolCallId] : [],
|
|
1147
|
+
);
|
|
1148
|
+
|
|
1105
1149
|
export function fromAgUiMessages(
|
|
1106
1150
|
messages: readonly unknown[],
|
|
1107
1151
|
options?: FromAgUiMessagesOptions,
|
|
1152
|
+
): CoreThreadMessageLike[] {
|
|
1153
|
+
return fromAgUiSnapshotMessages(messages, options);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Converts AG-UI messages, nesting each subagent run's messages under the tool
|
|
1158
|
+
* call `SUBAGENT_STARTED.parentToolCallId` announced for it. A message's
|
|
1159
|
+
* `subagentRunId` names its run; a run with no announced spawning call in
|
|
1160
|
+
* `messages` stays in the parent thread.
|
|
1161
|
+
*/
|
|
1162
|
+
export function fromAgUiSnapshotMessages(
|
|
1163
|
+
messages: readonly unknown[],
|
|
1164
|
+
options?: FromAgUiMessagesOptions,
|
|
1165
|
+
subagentParentToolCallIds?: ReadonlyMap<string, string | undefined>,
|
|
1166
|
+
): CoreThreadMessageLike[] {
|
|
1167
|
+
const records = messages.filter(isObject);
|
|
1168
|
+
const callOwners = new Map<string, string | undefined>();
|
|
1169
|
+
for (const record of records) {
|
|
1170
|
+
if (getString(record, "role") !== "assistant") continue;
|
|
1171
|
+
for (const toolCallId of readToolCallIds(record)) {
|
|
1172
|
+
callOwners.set(toolCallId, readSubagentRunId(record));
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const spawningCalls = new Map<string, string>();
|
|
1177
|
+
for (const record of records) {
|
|
1178
|
+
const runId = readSubagentRunId(record);
|
|
1179
|
+
if (runId === undefined || spawningCalls.has(runId)) continue;
|
|
1180
|
+
const toolCallId = subagentParentToolCallIds?.get(runId);
|
|
1181
|
+
if (toolCallId !== undefined && callOwners.has(toolCallId))
|
|
1182
|
+
spawningCalls.set(runId, toolCallId);
|
|
1183
|
+
}
|
|
1184
|
+
if (spawningCalls.size === 0) return convertAgUiMessages(messages, options);
|
|
1185
|
+
|
|
1186
|
+
const reachesRoot = (runId: string) => {
|
|
1187
|
+
let current: string | undefined = runId;
|
|
1188
|
+
for (let depth = 0; current !== undefined; depth++) {
|
|
1189
|
+
const toolCallId = spawningCalls.get(current);
|
|
1190
|
+
if (toolCallId === undefined || depth > MAX_SUBAGENT_DEPTH) return false;
|
|
1191
|
+
current = callOwners.get(toolCallId);
|
|
1192
|
+
}
|
|
1193
|
+
return true;
|
|
1194
|
+
};
|
|
1195
|
+
const nestedRuns = new Set([...spawningCalls.keys()].filter(reachesRoot));
|
|
1196
|
+
|
|
1197
|
+
const groups = new Map<string | undefined, unknown[]>();
|
|
1198
|
+
for (const record of records) {
|
|
1199
|
+
const ownRunId = readSubagentRunId(record);
|
|
1200
|
+
const answered =
|
|
1201
|
+
getString(record, "role") === "tool" ? getToolCallId(record) : undefined;
|
|
1202
|
+
const runId =
|
|
1203
|
+
answered !== undefined && callOwners.has(answered)
|
|
1204
|
+
? callOwners.get(answered)
|
|
1205
|
+
: ownRunId;
|
|
1206
|
+
const role = getString(record, "role");
|
|
1207
|
+
const group =
|
|
1208
|
+
role !== "user" &&
|
|
1209
|
+
role !== "system" &&
|
|
1210
|
+
role !== "developer" &&
|
|
1211
|
+
runId !== undefined &&
|
|
1212
|
+
nestedRuns.has(runId)
|
|
1213
|
+
? runId
|
|
1214
|
+
: undefined;
|
|
1215
|
+
const members = groups.get(group) ?? [];
|
|
1216
|
+
members.push(record);
|
|
1217
|
+
groups.set(group, members);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const runsBySpawningCall = new Map<string, string[]>();
|
|
1221
|
+
for (const runId of nestedRuns) {
|
|
1222
|
+
const toolCallId = spawningCalls.get(runId)!;
|
|
1223
|
+
runsBySpawningCall.set(toolCallId, [
|
|
1224
|
+
...(runsBySpawningCall.get(toolCallId) ?? []),
|
|
1225
|
+
runId,
|
|
1226
|
+
]);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const convertGroup = (group: string | undefined): CoreThreadMessageLike[] =>
|
|
1230
|
+
convertAgUiMessages(groups.get(group) ?? [], options).map((message) => {
|
|
1231
|
+
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
1232
|
+
return message;
|
|
1233
|
+
const nested: ThreadMessage[] = [];
|
|
1234
|
+
const content = message.content.map((part) => {
|
|
1235
|
+
if (part.type !== "tool-call") return part;
|
|
1236
|
+
const runIds = runsBySpawningCall.get(part.toolCallId);
|
|
1237
|
+
if (!runIds) return part;
|
|
1238
|
+
const subagents = runIds.map(toSubagentMessage);
|
|
1239
|
+
nested.push(...subagents);
|
|
1240
|
+
return { ...part, messages: [...(part.messages ?? []), ...subagents] };
|
|
1241
|
+
});
|
|
1242
|
+
return liftSubagentState({ ...message, content }, nested);
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
const toSubagentMessage = (runId: string): ThreadMessage => {
|
|
1246
|
+
const messages = convertGroup(runId).filter(
|
|
1247
|
+
(message) => message.role === "assistant",
|
|
1248
|
+
);
|
|
1249
|
+
const folded = messages
|
|
1250
|
+
.map((message) =>
|
|
1251
|
+
typeof message.content === "string"
|
|
1252
|
+
? {
|
|
1253
|
+
...message,
|
|
1254
|
+
content: message.content
|
|
1255
|
+
? [{ type: "text" as const, text: message.content }]
|
|
1256
|
+
: [],
|
|
1257
|
+
}
|
|
1258
|
+
: message,
|
|
1259
|
+
)
|
|
1260
|
+
.reduce<CoreThreadMessageLike | undefined>(
|
|
1261
|
+
(merged, message) =>
|
|
1262
|
+
merged ? foldTurnRecords(merged, message) : message,
|
|
1263
|
+
undefined,
|
|
1264
|
+
);
|
|
1265
|
+
const content = Array.isArray(folded?.content) ? folded.content : [];
|
|
1266
|
+
const pendingToolCallIds = new Set(
|
|
1267
|
+
messages
|
|
1268
|
+
.filter(
|
|
1269
|
+
(message) =>
|
|
1270
|
+
message.status?.type === "requires-action" &&
|
|
1271
|
+
message.status.reason === "tool-calls",
|
|
1272
|
+
)
|
|
1273
|
+
.flatMap((message) => admittedUnresolvedToolCallIds(message, true)),
|
|
1274
|
+
);
|
|
1275
|
+
const metadata = pendingToolCallIds.size
|
|
1276
|
+
? withPendingToolCallIds(folded?.metadata, pendingToolCallIds)
|
|
1277
|
+
: folded?.metadata;
|
|
1278
|
+
return fromThreadMessageLike(
|
|
1279
|
+
{
|
|
1280
|
+
id: runId,
|
|
1281
|
+
role: "assistant",
|
|
1282
|
+
content,
|
|
1283
|
+
...(metadata ? { metadata } : {}),
|
|
1284
|
+
},
|
|
1285
|
+
runId,
|
|
1286
|
+
subagentStatus(folded?.metadata, messages),
|
|
1287
|
+
);
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
return convertGroup(undefined);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const hasUnresolvedToolCall = (content: readonly unknown[]) =>
|
|
1294
|
+
content.some(
|
|
1295
|
+
(part) =>
|
|
1296
|
+
isObject(part) && part.type === "tool-call" && part.result === undefined,
|
|
1297
|
+
);
|
|
1298
|
+
|
|
1299
|
+
function admittedUnresolvedToolCallIds(
|
|
1300
|
+
message: CoreThreadMessageLike,
|
|
1301
|
+
includeNested: boolean,
|
|
1302
|
+
): string[] {
|
|
1303
|
+
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
1304
|
+
return [];
|
|
1305
|
+
const listed = readPersistedPendingToolCallIds(message.metadata);
|
|
1306
|
+
if (listed) return listed;
|
|
1307
|
+
return message.content.flatMap((part) => {
|
|
1308
|
+
if (part.type !== "tool-call") return [];
|
|
1309
|
+
const calls = includeNested
|
|
1310
|
+
? [
|
|
1311
|
+
part,
|
|
1312
|
+
...Array.from(
|
|
1313
|
+
walkToolCallTree(part.messages ?? []),
|
|
1314
|
+
({ part }) => part,
|
|
1315
|
+
),
|
|
1316
|
+
]
|
|
1317
|
+
: [part];
|
|
1318
|
+
return calls.flatMap((call) =>
|
|
1319
|
+
call.result === undefined && call.toolCallId ? [call.toolCallId] : [],
|
|
1320
|
+
);
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function withPendingToolCallIds(
|
|
1325
|
+
metadata: CoreThreadMessageLike["metadata"],
|
|
1326
|
+
ids: ReadonlySet<string>,
|
|
1327
|
+
): CoreThreadMessageLike["metadata"] {
|
|
1328
|
+
return {
|
|
1329
|
+
...(isObject(metadata) ? metadata : {}),
|
|
1330
|
+
custom: {
|
|
1331
|
+
...readCustomMetadata(metadata),
|
|
1332
|
+
[AG_UI_METADATA_NAMESPACE]: {
|
|
1333
|
+
...readNamespacedMetadata(metadata),
|
|
1334
|
+
pendingToolCallIds: [...ids],
|
|
1335
|
+
},
|
|
1336
|
+
},
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function subagentStatus(
|
|
1341
|
+
metadata: unknown,
|
|
1342
|
+
messages: readonly CoreThreadMessageLike[],
|
|
1343
|
+
): MessageStatus {
|
|
1344
|
+
const hasInterrupt = readPersistedInterrupts(metadata) !== undefined;
|
|
1345
|
+
const hasPendingToolCall = messages.some(
|
|
1346
|
+
(message) =>
|
|
1347
|
+
message.status?.type === "requires-action" &&
|
|
1348
|
+
message.status.reason === "tool-calls",
|
|
1349
|
+
);
|
|
1350
|
+
return hasInterrupt || hasPendingToolCall
|
|
1351
|
+
? getAutoStatus(false, false, hasInterrupt, hasPendingToolCall, undefined)
|
|
1352
|
+
: { type: "complete", reason: "unknown" };
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* A nested run's open interrupts and unanswered calls surface on the message
|
|
1357
|
+
* that spawned it, as the live run does.
|
|
1358
|
+
*/
|
|
1359
|
+
function liftSubagentState(
|
|
1360
|
+
message: CoreThreadMessageLike,
|
|
1361
|
+
nested: readonly ThreadMessage[],
|
|
1362
|
+
): CoreThreadMessageLike {
|
|
1363
|
+
const waiting = nested.filter(
|
|
1364
|
+
(subagent) => subagent.status?.type === "requires-action",
|
|
1365
|
+
);
|
|
1366
|
+
if (waiting.length === 0) return message;
|
|
1367
|
+
const interrupts = [
|
|
1368
|
+
...(readPersistedInterrupts(message.metadata) ?? []),
|
|
1369
|
+
...waiting.flatMap(
|
|
1370
|
+
(subagent) => readPersistedInterrupts(subagent.metadata) ?? [],
|
|
1371
|
+
),
|
|
1372
|
+
];
|
|
1373
|
+
const custom = readCustomMetadata(message.metadata);
|
|
1374
|
+
const metadata = {
|
|
1375
|
+
...(isObject(message.metadata) ? message.metadata : {}),
|
|
1376
|
+
custom: {
|
|
1377
|
+
...custom,
|
|
1378
|
+
[AG_UI_METADATA_NAMESPACE]: {
|
|
1379
|
+
...readNamespacedMetadata(message.metadata),
|
|
1380
|
+
...(interrupts.length > 0 ? { interrupts } : {}),
|
|
1381
|
+
},
|
|
1382
|
+
},
|
|
1383
|
+
};
|
|
1384
|
+
const hasInterrupt = interrupts.length > 0;
|
|
1385
|
+
const hasPendingToolCall =
|
|
1386
|
+
waiting.some(
|
|
1387
|
+
(subagent) =>
|
|
1388
|
+
subagent.status?.type === "requires-action" &&
|
|
1389
|
+
subagent.status.reason === "tool-calls",
|
|
1390
|
+
) ||
|
|
1391
|
+
(Array.isArray(message.content) && hasUnresolvedToolCall(message.content));
|
|
1392
|
+
const pendingToolCallIds = new Set([
|
|
1393
|
+
...admittedUnresolvedToolCallIds(message, false),
|
|
1394
|
+
...waiting.flatMap((subagent) =>
|
|
1395
|
+
subagent.status?.type === "requires-action" &&
|
|
1396
|
+
subagent.status.reason === "tool-calls"
|
|
1397
|
+
? admittedUnresolvedToolCallIds(subagent, true)
|
|
1398
|
+
: [],
|
|
1399
|
+
),
|
|
1400
|
+
]);
|
|
1401
|
+
return {
|
|
1402
|
+
...message,
|
|
1403
|
+
metadata: pendingToolCallIds.size
|
|
1404
|
+
? withPendingToolCallIds(metadata, pendingToolCallIds)
|
|
1405
|
+
: metadata,
|
|
1406
|
+
status: getAutoStatus(
|
|
1407
|
+
false,
|
|
1408
|
+
false,
|
|
1409
|
+
hasInterrupt,
|
|
1410
|
+
hasPendingToolCall,
|
|
1411
|
+
undefined,
|
|
1412
|
+
),
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function convertAgUiMessages(
|
|
1417
|
+
messages: readonly unknown[],
|
|
1418
|
+
options?: FromAgUiMessagesOptions,
|
|
1108
1419
|
): CoreThreadMessageLike[] {
|
|
1109
1420
|
const showThinking = options?.showThinking ?? true;
|
|
1110
1421
|
const converted: CoreThreadMessageLike[] = [];
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { ChatModelRunResult } from "@assistant-ui/core";
|
|
3
|
+
import { RunAggregator } from "./run-aggregator";
|
|
4
|
+
import { fromAgUiMessages, toAgUiMessages } from "./conversions";
|
|
5
|
+
import { parseAgUiEvent } from "../event-parser";
|
|
6
|
+
|
|
7
|
+
describe("AG-UI tool result metadata", () => {
|
|
8
|
+
it("keeps interleaved tool result metadata through finish and message export/import", () => {
|
|
9
|
+
const expectedSecondMetadata = {
|
|
10
|
+
trace: "second-final",
|
|
11
|
+
attempts: 0,
|
|
12
|
+
optional: null,
|
|
13
|
+
nested: { value: "after" },
|
|
14
|
+
};
|
|
15
|
+
const updates: ChatModelRunResult[] = [];
|
|
16
|
+
const aggregator = new RunAggregator({
|
|
17
|
+
showThinking: false,
|
|
18
|
+
logger: { debug: () => {}, error: () => {} },
|
|
19
|
+
emit: (update) => updates.push(update),
|
|
20
|
+
});
|
|
21
|
+
aggregator.handle({ type: "RUN_STARTED", runId: "run" });
|
|
22
|
+
for (const toolCallId of ["first", "second", "empty"]) {
|
|
23
|
+
aggregator.handle({
|
|
24
|
+
type: "TOOL_CALL_START",
|
|
25
|
+
toolCallId,
|
|
26
|
+
toolCallName: "lookup",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const event of [
|
|
31
|
+
{
|
|
32
|
+
type: "TOOL_CALL_RESULT",
|
|
33
|
+
toolCallId: "second",
|
|
34
|
+
messageId: "result-second",
|
|
35
|
+
content: "0",
|
|
36
|
+
metadata: {
|
|
37
|
+
trace: "second",
|
|
38
|
+
attempts: 0,
|
|
39
|
+
optional: null,
|
|
40
|
+
nested: { old: true, value: "before" },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: "TOOL_CALL_RESULT",
|
|
45
|
+
toolCallId: "first",
|
|
46
|
+
messageId: "result-first",
|
|
47
|
+
content: "false",
|
|
48
|
+
metadata: { trace: "first", labels: [] },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
type: "TOOL_CALL_RESULT",
|
|
52
|
+
toolCallId: "empty",
|
|
53
|
+
messageId: "result-empty",
|
|
54
|
+
content: "",
|
|
55
|
+
metadata: { trace: "empty" },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
type: "TOOL_CALL_RESULT",
|
|
59
|
+
toolCallId: "second",
|
|
60
|
+
messageId: "result-second",
|
|
61
|
+
content: "0",
|
|
62
|
+
metadata: { trace: "second-final", nested: { value: "after" } },
|
|
63
|
+
},
|
|
64
|
+
]) {
|
|
65
|
+
const parsed = parseAgUiEvent(event);
|
|
66
|
+
if (!parsed) throw new Error("Expected a parsed result event");
|
|
67
|
+
aggregator.handle(parsed);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const liveSecond = updates
|
|
71
|
+
.at(-1)
|
|
72
|
+
?.content?.find(
|
|
73
|
+
(part) => part.type === "tool-call" && part.toolCallId === "second",
|
|
74
|
+
);
|
|
75
|
+
if (liveSecond?.type !== "tool-call") {
|
|
76
|
+
throw new Error("Expected the streamed second tool call");
|
|
77
|
+
}
|
|
78
|
+
expect(liveSecond).toMatchObject({ result: 0 });
|
|
79
|
+
expect(liveSecond.providerMetadata).toEqual({
|
|
80
|
+
agui: { messageMetadata: expectedSecondMetadata },
|
|
81
|
+
});
|
|
82
|
+
aggregator.handle({ type: "RUN_FINISHED", runId: "run" });
|
|
83
|
+
const content = updates.at(-1)?.content;
|
|
84
|
+
expect(content).toEqual(
|
|
85
|
+
expect.arrayContaining([
|
|
86
|
+
expect.objectContaining({
|
|
87
|
+
toolCallId: "first",
|
|
88
|
+
result: false,
|
|
89
|
+
providerMetadata: {
|
|
90
|
+
agui: { messageMetadata: { trace: "first", labels: [] } },
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
toolCallId: "second",
|
|
95
|
+
result: 0,
|
|
96
|
+
providerMetadata: {
|
|
97
|
+
agui: {
|
|
98
|
+
messageMetadata: expectedSecondMetadata,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
toolCallId: "empty",
|
|
104
|
+
result: "",
|
|
105
|
+
providerMetadata: {
|
|
106
|
+
agui: { messageMetadata: { trace: "empty" } },
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
]),
|
|
110
|
+
);
|
|
111
|
+
const exported = toAgUiMessages([
|
|
112
|
+
{ id: "assistant", role: "assistant", content: content ?? [] },
|
|
113
|
+
]);
|
|
114
|
+
expect(exported).toEqual(
|
|
115
|
+
expect.arrayContaining([
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
role: "tool",
|
|
118
|
+
toolCallId: "first",
|
|
119
|
+
metadata: { trace: "first", labels: [] },
|
|
120
|
+
}),
|
|
121
|
+
expect.objectContaining({
|
|
122
|
+
role: "tool",
|
|
123
|
+
toolCallId: "second",
|
|
124
|
+
metadata: expectedSecondMetadata,
|
|
125
|
+
}),
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
role: "tool",
|
|
128
|
+
toolCallId: "empty",
|
|
129
|
+
metadata: { trace: "empty" },
|
|
130
|
+
}),
|
|
131
|
+
]),
|
|
132
|
+
);
|
|
133
|
+
expect(fromAgUiMessages(exported)).toEqual(
|
|
134
|
+
expect.arrayContaining([
|
|
135
|
+
expect.objectContaining({
|
|
136
|
+
content: expect.arrayContaining([
|
|
137
|
+
expect.objectContaining({
|
|
138
|
+
toolCallId: "second",
|
|
139
|
+
result: 0,
|
|
140
|
+
providerMetadata: {
|
|
141
|
+
agui: {
|
|
142
|
+
messageMetadata: expectedSecondMetadata,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
]),
|
|
147
|
+
}),
|
|
148
|
+
]),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
});
|