@assistant-ui/react-ag-ui 0.0.59 → 0.0.61
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/dist/agUiExtras.d.ts +2 -5
- package/dist/agUiExtras.d.ts.map +1 -1
- package/dist/hooks.d.ts +9 -12
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -0
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts +153 -146
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +146 -26
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +54 -56
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +164 -23
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +98 -98
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +35 -2
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +109 -111
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +7 -9
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
- package/dist/runtime/event-parser.d.ts +5 -8
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/interrupt-internals.d.ts +4 -7
- package/dist/runtime/interrupt-internals.d.ts.map +1 -1
- package/dist/runtime/logger.d.ts +4 -7
- package/dist/runtime/logger.d.ts.map +1 -1
- package/dist/runtime/mcp-tool-result.d.ts +7 -10
- package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
- package/dist/runtime/types.d.ts +248 -250
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.d.ts +15 -18
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +9 -2
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +12 -11
- package/src/runtime/AgUiThreadRuntimeCore.ts +246 -30
- package/src/runtime/adapter/conversions.content.test.ts +56 -0
- package/src/runtime/adapter/conversions.ts +278 -36
- package/src/runtime/adapter/run-aggregator.ts +53 -2
- package/src/useAgUiRuntime.history.test.tsx +93 -2
- package/src/useAgUiRuntime.thread-switch.test.tsx +84 -4
- package/src/useAgUiRuntime.ts +12 -3
- package/src/useAgUiRuntime.voice.test.tsx +402 -0
|
@@ -5,6 +5,7 @@ import { generateId } from "@assistant-ui/core";
|
|
|
5
5
|
import type {
|
|
6
6
|
ThreadMessageLike as CoreThreadMessageLike,
|
|
7
7
|
PartProviderMetadata,
|
|
8
|
+
ReasoningMessagePart,
|
|
8
9
|
ThreadMessage,
|
|
9
10
|
ToolCallMessagePartMcpMetadata,
|
|
10
11
|
ToolModelContentPart,
|
|
@@ -19,12 +20,14 @@ import type { ReadonlyJSONObject } from "assistant-stream/utils";
|
|
|
19
20
|
import {
|
|
20
21
|
AG_UI_METADATA_NAMESPACE,
|
|
21
22
|
A2UI_SURFACE_ACTIVITY_TYPE,
|
|
23
|
+
MCP_APPS_ACTIVITY_TYPE,
|
|
22
24
|
type AgUiCustomMetadata,
|
|
23
25
|
type AgUiOpaqueReasoning,
|
|
24
26
|
} from "./run-aggregator";
|
|
25
27
|
import {
|
|
26
28
|
applyA2uiOperations,
|
|
27
29
|
convertSurfaceToUISpec,
|
|
30
|
+
surfaceToOperations,
|
|
28
31
|
type A2uiState,
|
|
29
32
|
type A2uiSurfaceState,
|
|
30
33
|
} from "@assistant-ui/react-generative-ui/a2ui";
|
|
@@ -44,14 +47,14 @@ type AttachmentLike = {
|
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
type ThreadMessageLike = {
|
|
47
|
-
id?: string;
|
|
50
|
+
id?: string | undefined;
|
|
48
51
|
role: string;
|
|
49
52
|
content: unknown;
|
|
50
53
|
metadata?: unknown;
|
|
51
|
-
name?: string;
|
|
52
|
-
toolCallId?: string;
|
|
53
|
-
error?: string;
|
|
54
|
-
attachments?: readonly AttachmentLike[];
|
|
54
|
+
name?: string | undefined;
|
|
55
|
+
toolCallId?: string | undefined;
|
|
56
|
+
error?: string | undefined;
|
|
57
|
+
attachments?: readonly AttachmentLike[] | undefined;
|
|
55
58
|
};
|
|
56
59
|
|
|
57
60
|
type NormalizedThreadMessageLike = ThreadMessageLike & { id: string };
|
|
@@ -103,6 +106,7 @@ type ToolCallPart = {
|
|
|
103
106
|
argsText?: string;
|
|
104
107
|
args?: ReadonlyJSONObject;
|
|
105
108
|
result?: unknown;
|
|
109
|
+
artifact?: unknown;
|
|
106
110
|
isError?: boolean;
|
|
107
111
|
modelContent?: readonly ToolModelContentPart[];
|
|
108
112
|
unstable_toolMessageId?: string;
|
|
@@ -240,7 +244,9 @@ function mediaTypeForMime(mimeType: string | undefined): MediaInputType {
|
|
|
240
244
|
// Build an AG-UI multimodal source from a data URL, raw base64 payload, or an
|
|
241
245
|
// http(s) URL. A `url` source may omit the mime type; a `data` source always
|
|
242
246
|
// resolves one (falling back to application/octet-stream). An explicit
|
|
243
|
-
// `sourceType: "url"` on the part forces the url leg for non-http references
|
|
247
|
+
// `sourceType: "url"` on the part forces the url leg for non-http references,
|
|
248
|
+
// and a data URL that is not base64 takes it too, since a `data` source
|
|
249
|
+
// carries base64 bytes.
|
|
244
250
|
function buildInputSource(
|
|
245
251
|
value: string,
|
|
246
252
|
declaredMimeType: string | undefined,
|
|
@@ -249,7 +255,8 @@ function buildInputSource(
|
|
|
249
255
|
const source = resolveFilePartSource({
|
|
250
256
|
data: value,
|
|
251
257
|
mimeType: declaredMimeType ?? "application/octet-stream",
|
|
252
|
-
sourceType
|
|
258
|
+
sourceType:
|
|
259
|
+
/^data:/i.test(value) && !parseDataUrl(value) ? "url" : sourceType,
|
|
253
260
|
});
|
|
254
261
|
if (source.kind === "url") {
|
|
255
262
|
return declaredMimeType !== undefined
|
|
@@ -717,6 +724,7 @@ function attachA2uiSurfaces(
|
|
|
717
724
|
args: spec as unknown as ReadonlyJSONObject,
|
|
718
725
|
argsText: JSON.stringify(spec),
|
|
719
726
|
result: {},
|
|
727
|
+
artifact: { a2ui: surfaceToOperations(surface) },
|
|
720
728
|
});
|
|
721
729
|
}
|
|
722
730
|
|
|
@@ -733,6 +741,128 @@ function attachA2uiSurfaces(
|
|
|
733
741
|
return { ...message, content: [...preserved, ...a2uiParts] };
|
|
734
742
|
}
|
|
735
743
|
|
|
744
|
+
// A tool call that arrives without a `parentMessageId` has no record to join, so
|
|
745
|
+
// the client opens one keyed by the call id. An assistant record whose own id is
|
|
746
|
+
// a call it carries is that container: an id the client invented for the call
|
|
747
|
+
// rather than a boundary an agent drew. The prose of the turn lands in records
|
|
748
|
+
// of its own on either side of it, and nothing on the wire binds them to the
|
|
749
|
+
// run that produced them.
|
|
750
|
+
function isSyntheticToolCallContainer(message: CoreThreadMessageLike): boolean {
|
|
751
|
+
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
752
|
+
return false;
|
|
753
|
+
let carriesOwnId = false;
|
|
754
|
+
for (const part of message.content) {
|
|
755
|
+
if (!isObject(part)) continue;
|
|
756
|
+
// The container is opened empty, so text on the record means an agent
|
|
757
|
+
// addressed it and the id is one it chose.
|
|
758
|
+
if (part.type === "text") return false;
|
|
759
|
+
if (
|
|
760
|
+
part.type === "tool-call" &&
|
|
761
|
+
getString(part, "toolCallId") === message.id
|
|
762
|
+
) {
|
|
763
|
+
carriesOwnId = true;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return carriesOwnId;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function carriesPart(message: CoreThreadMessageLike, type: string): boolean {
|
|
770
|
+
return (
|
|
771
|
+
Array.isArray(message.content) &&
|
|
772
|
+
message.content.some((part) => isObject(part) && part.type === type)
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function carriesText(message: CoreThreadMessageLike): boolean {
|
|
777
|
+
return carriesPart(message, "text");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// A container joins the assistant record ahead of it when that record holds
|
|
781
|
+
// prose or calls of the turn. A released reasoning record holds neither: its
|
|
782
|
+
// part leaves the export under the record's own id, so a message merged under
|
|
783
|
+
// that id would put two records with one id on the wire.
|
|
784
|
+
function opensTurn(message: CoreThreadMessageLike): boolean {
|
|
785
|
+
return (
|
|
786
|
+
message.role === "assistant" &&
|
|
787
|
+
(carriesText(message) || carriesPart(message, "tool-call"))
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// The parts of the answer fold onto the container ahead of them. A record
|
|
792
|
+
// carrying a tool call an agent addressed is a boundary of its own and keeps
|
|
793
|
+
// its message.
|
|
794
|
+
function answersToolCallContainer(message: CoreThreadMessageLike): boolean {
|
|
795
|
+
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
796
|
+
return false;
|
|
797
|
+
if (message.content.length === 0) return false;
|
|
798
|
+
return !message.content.some(
|
|
799
|
+
(part) => isObject(part) && part.type === "tool-call",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function readCustomMetadata(metadata: unknown): Record<string, unknown> {
|
|
804
|
+
if (!isObject(metadata) || !isObject(metadata.custom)) return {};
|
|
805
|
+
return metadata.custom;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function readNamespacedMetadata(metadata: unknown): Record<string, unknown> {
|
|
809
|
+
const namespaced = readCustomMetadata(metadata)[AG_UI_METADATA_NAMESPACE];
|
|
810
|
+
return isObject(namespaced) ? namespaced : {};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function foldTurnRecords(
|
|
814
|
+
previous: CoreThreadMessageLike,
|
|
815
|
+
message: CoreThreadMessageLike,
|
|
816
|
+
): CoreThreadMessageLike {
|
|
817
|
+
const interrupts = [
|
|
818
|
+
...(readPersistedInterrupts(previous.metadata) ?? []),
|
|
819
|
+
...(readPersistedInterrupts(message.metadata) ?? []),
|
|
820
|
+
];
|
|
821
|
+
// An entry that rode a record behind the turn's calls sat ahead of content
|
|
822
|
+
// that now follows them, so it is replayed after the merged record and its
|
|
823
|
+
// tool results rather than ahead of a message that no longer starts there.
|
|
824
|
+
// One that rode the container opening the turn sat ahead of every call and
|
|
825
|
+
// stays ahead of the record.
|
|
826
|
+
const trailsCalls = carriesPart(previous, "tool-call");
|
|
827
|
+
const opaqueReasoning = [
|
|
828
|
+
...readOpaqueReasoning(previous.metadata),
|
|
829
|
+
...readOpaqueReasoning(message.metadata).map((entry) =>
|
|
830
|
+
trailsCalls ? { ...entry, after: true } : entry,
|
|
831
|
+
),
|
|
832
|
+
];
|
|
833
|
+
const namespaced = {
|
|
834
|
+
...readNamespacedMetadata(previous.metadata),
|
|
835
|
+
...readNamespacedMetadata(message.metadata),
|
|
836
|
+
...(interrupts.length > 0 ? { interrupts } : {}),
|
|
837
|
+
...(opaqueReasoning.length > 0 ? { opaqueReasoning } : {}),
|
|
838
|
+
} satisfies AgUiCustomMetadata & Record<string, unknown>;
|
|
839
|
+
const custom = {
|
|
840
|
+
...readCustomMetadata(previous.metadata),
|
|
841
|
+
...readCustomMetadata(message.metadata),
|
|
842
|
+
...(Object.keys(namespaced).length > 0
|
|
843
|
+
? { [AG_UI_METADATA_NAMESPACE]: namespaced }
|
|
844
|
+
: {}),
|
|
845
|
+
};
|
|
846
|
+
const metadata = {
|
|
847
|
+
...(isObject(previous.metadata) ? previous.metadata : {}),
|
|
848
|
+
...(isObject(message.metadata) ? message.metadata : {}),
|
|
849
|
+
...(Object.keys(custom).length > 0 ? { custom } : {}),
|
|
850
|
+
};
|
|
851
|
+
return {
|
|
852
|
+
...previous,
|
|
853
|
+
...message,
|
|
854
|
+
// A container's id names a call rather than the turn, so the record the
|
|
855
|
+
// agent addressed its prose to wins: that is the id the next run has to
|
|
856
|
+
// carry for the agent to recognize the message it wrote.
|
|
857
|
+
id: carriesText(message) ? message.id : previous.id,
|
|
858
|
+
content: [
|
|
859
|
+
...(Array.isArray(previous.content) ? previous.content : []),
|
|
860
|
+
...(Array.isArray(message.content) ? message.content : []),
|
|
861
|
+
],
|
|
862
|
+
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
|
863
|
+
} as CoreThreadMessageLike;
|
|
864
|
+
}
|
|
865
|
+
|
|
736
866
|
export type FromAgUiMessagesOptions = {
|
|
737
867
|
/**
|
|
738
868
|
* Whether to convert `reasoning` messages into visible reasoning parts.
|
|
@@ -749,11 +879,49 @@ export function fromAgUiMessages(
|
|
|
749
879
|
const converted: CoreThreadMessageLike[] = [];
|
|
750
880
|
const a2uiBuckets = new Map<string, A2uiState>();
|
|
751
881
|
const a2uiBucketOwnerIndices = new Map<string, number>();
|
|
882
|
+
const activityParts = new Map<
|
|
883
|
+
string,
|
|
884
|
+
{ ownerIndex: number; part: { type: "data"; name: string; data: unknown } }
|
|
885
|
+
>();
|
|
752
886
|
// A zero-data-retention run carries its payload in encryptedValue with no
|
|
753
887
|
// readable content, so the record has no part to become and rides on the
|
|
754
888
|
// neighbouring message instead of being dropped. The anchor is the index the
|
|
755
889
|
// next pushed message will occupy, which is where it sat on the wire.
|
|
756
890
|
const opaqueReasoning: (AgUiOpaqueReasoning & { anchor: number })[] = [];
|
|
891
|
+
// A reasoning record belongs to the assistant record that follows it, which is
|
|
892
|
+
// the binding the export writes and the one the reference integrations read
|
|
893
|
+
// back. Holding it until that assistant arrives rebuilds the pair as the
|
|
894
|
+
// single message the live run produced.
|
|
895
|
+
const pendingReasoning: {
|
|
896
|
+
id: string | undefined;
|
|
897
|
+
part: ReasoningMessagePart;
|
|
898
|
+
}[] = [];
|
|
899
|
+
|
|
900
|
+
const flushPendingReasoning = () => {
|
|
901
|
+
for (const { id, part } of pendingReasoning) {
|
|
902
|
+
converted.push({
|
|
903
|
+
id: id ?? generateId(),
|
|
904
|
+
role: "assistant",
|
|
905
|
+
content: [part],
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
pendingReasoning.length = 0;
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
const withPendingReasoning = (
|
|
912
|
+
message: CoreThreadMessageLike,
|
|
913
|
+
): CoreThreadMessageLike => {
|
|
914
|
+
if (pendingReasoning.length === 0) return message;
|
|
915
|
+
const parts = pendingReasoning.map(({ part }) => part);
|
|
916
|
+
pendingReasoning.length = 0;
|
|
917
|
+
return {
|
|
918
|
+
...message,
|
|
919
|
+
content: [
|
|
920
|
+
...parts,
|
|
921
|
+
...(Array.isArray(message.content) ? message.content : []),
|
|
922
|
+
],
|
|
923
|
+
};
|
|
924
|
+
};
|
|
757
925
|
|
|
758
926
|
for (const rawMessage of messages) {
|
|
759
927
|
if (!isObject(rawMessage)) continue;
|
|
@@ -761,6 +929,7 @@ export function fromAgUiMessages(
|
|
|
761
929
|
if (!role) continue;
|
|
762
930
|
|
|
763
931
|
if (role === "tool") {
|
|
932
|
+
flushPendingReasoning();
|
|
764
933
|
const toolCallId = getToolCallId(rawMessage) ?? `tool-${generateId()}`;
|
|
765
934
|
const toolMessageId = getString(rawMessage, "id");
|
|
766
935
|
const modelContent = extractText(rawMessage.content);
|
|
@@ -862,15 +1031,14 @@ export function fromAgUiMessages(
|
|
|
862
1031
|
}
|
|
863
1032
|
|
|
864
1033
|
if (role === "activity") {
|
|
865
|
-
// Only a2ui-surface activity messages have an assistant-part equivalent
|
|
866
|
-
// to rehydrate; other activity types still have no surface to repaint.
|
|
867
1034
|
const activityType = getString(rawMessage, "activityType");
|
|
868
|
-
if (activityType
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1035
|
+
if (activityType === undefined || activityType === MCP_APPS_ACTIVITY_TYPE)
|
|
1036
|
+
continue;
|
|
1037
|
+
|
|
1038
|
+
// A surface belongs to the turn that painted it, and held reasoning is a
|
|
1039
|
+
// nearer antecedent than the previous turn's assistant record, so it is
|
|
1040
|
+
// released here rather than folded past this record.
|
|
1041
|
+
flushPendingReasoning();
|
|
874
1042
|
|
|
875
1043
|
let ownerIndex = -1;
|
|
876
1044
|
for (let i = converted.length - 1; i >= 0; i--) {
|
|
@@ -883,6 +1051,48 @@ export function fromAgUiMessages(
|
|
|
883
1051
|
if (ownerIndex === -1) continue;
|
|
884
1052
|
|
|
885
1053
|
const owner = converted[ownerIndex]!;
|
|
1054
|
+
if (activityType !== A2UI_SURFACE_ACTIVITY_TYPE) {
|
|
1055
|
+
const bucketKey =
|
|
1056
|
+
getString(rawMessage, "id") ?? `agui-activity:${activityType}`;
|
|
1057
|
+
const part = {
|
|
1058
|
+
type: "data" as const,
|
|
1059
|
+
name: `agui-activity/${activityType}`,
|
|
1060
|
+
data: rawMessage.content,
|
|
1061
|
+
};
|
|
1062
|
+
const existing = activityParts.get(bucketKey);
|
|
1063
|
+
const existingOwner = existing && converted[existing.ownerIndex];
|
|
1064
|
+
const existingIndex =
|
|
1065
|
+
existingOwner && Array.isArray(existingOwner.content)
|
|
1066
|
+
? existingOwner.content.indexOf(existing.part)
|
|
1067
|
+
: -1;
|
|
1068
|
+
if (existing && existingOwner && existingIndex !== -1) {
|
|
1069
|
+
const content = [...(existingOwner.content as unknown[])];
|
|
1070
|
+
content[existingIndex] = part;
|
|
1071
|
+
converted[existing.ownerIndex] = {
|
|
1072
|
+
...existingOwner,
|
|
1073
|
+
content: content as typeof existingOwner.content,
|
|
1074
|
+
};
|
|
1075
|
+
activityParts.set(bucketKey, {
|
|
1076
|
+
ownerIndex: existing.ownerIndex,
|
|
1077
|
+
part,
|
|
1078
|
+
});
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const content = Array.isArray(owner.content) ? owner.content : [];
|
|
1083
|
+
activityParts.set(bucketKey, { ownerIndex, part });
|
|
1084
|
+
converted[ownerIndex] = {
|
|
1085
|
+
...owner,
|
|
1086
|
+
content: [...content, part],
|
|
1087
|
+
};
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const activityContent = isObject(rawMessage.content)
|
|
1092
|
+
? (rawMessage.content as Record<string, unknown>)
|
|
1093
|
+
: null;
|
|
1094
|
+
const operations = activityContent?.["a2ui_operations"];
|
|
1095
|
+
if (!Array.isArray(operations)) continue;
|
|
886
1096
|
const bucketKey = getString(rawMessage, "id") ?? "a2ui:anonymous";
|
|
887
1097
|
const { state } = applyA2uiOperations(new Map(), operations);
|
|
888
1098
|
a2uiBuckets.delete(bucketKey);
|
|
@@ -903,7 +1113,9 @@ export function fromAgUiMessages(
|
|
|
903
1113
|
}
|
|
904
1114
|
|
|
905
1115
|
if (role === "assistant") {
|
|
906
|
-
converted.push(
|
|
1116
|
+
converted.push(
|
|
1117
|
+
withPendingReasoning(toAssistantSnapshotMessage(rawMessage)),
|
|
1118
|
+
);
|
|
907
1119
|
continue;
|
|
908
1120
|
}
|
|
909
1121
|
|
|
@@ -916,6 +1128,9 @@ export function fromAgUiMessages(
|
|
|
916
1128
|
// accept the next run.
|
|
917
1129
|
const opaqueId = getString(rawMessage, "id");
|
|
918
1130
|
if (opaqueId?.trim() && encryptedValue?.trim()) {
|
|
1131
|
+
// The anchor counts materialized messages, so anything still held
|
|
1132
|
+
// takes its own slot rather than folding past this record.
|
|
1133
|
+
flushPendingReasoning();
|
|
919
1134
|
opaqueReasoning.push({
|
|
920
1135
|
id: opaqueId,
|
|
921
1136
|
encryptedValue,
|
|
@@ -939,31 +1154,37 @@ export function fromAgUiMessages(
|
|
|
939
1154
|
}
|
|
940
1155
|
continue;
|
|
941
1156
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1157
|
+
const rawReasoningId = getString(rawMessage, "id");
|
|
1158
|
+
// A blank id still wins over a synthesized one on export, which would put
|
|
1159
|
+
// an unaddressable record on the wire.
|
|
1160
|
+
const reasoningId = rawReasoningId?.trim() ? rawReasoningId : undefined;
|
|
1161
|
+
// The fold costs the record its own message id, so it rides the part
|
|
1162
|
+
// instead: the export re-emits each block under the id it arrived with.
|
|
1163
|
+
const meta = {
|
|
1164
|
+
...(reasoningId !== undefined ? { reasoningId } : {}),
|
|
1165
|
+
...(encryptedValue !== undefined ? { encryptedValue } : {}),
|
|
1166
|
+
};
|
|
1167
|
+
pendingReasoning.push({
|
|
1168
|
+
id: reasoningId,
|
|
1169
|
+
part: {
|
|
1170
|
+
type: "reasoning",
|
|
1171
|
+
text,
|
|
1172
|
+
...(Object.keys(meta).length > 0
|
|
1173
|
+
? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
|
|
1174
|
+
: {}),
|
|
1175
|
+
},
|
|
958
1176
|
});
|
|
959
1177
|
continue;
|
|
960
1178
|
}
|
|
961
1179
|
|
|
962
1180
|
if (role === "user" || role === "system" || role === "developer") {
|
|
1181
|
+
flushPendingReasoning();
|
|
963
1182
|
converted.push(toUserOrSystemSnapshotMessage(role, rawMessage));
|
|
964
1183
|
}
|
|
965
1184
|
}
|
|
966
1185
|
|
|
1186
|
+
flushPendingReasoning();
|
|
1187
|
+
|
|
967
1188
|
for (const { anchor, ...entry } of opaqueReasoning) {
|
|
968
1189
|
// Nothing followed it on the wire, so it trails the last message instead.
|
|
969
1190
|
const trailing = anchor >= converted.length;
|
|
@@ -976,8 +1197,29 @@ export function fromAgUiMessages(
|
|
|
976
1197
|
]);
|
|
977
1198
|
}
|
|
978
1199
|
|
|
979
|
-
|
|
980
|
-
|
|
1200
|
+
// A turn that called a tool without addressing a record sits on the wire as
|
|
1201
|
+
// a container beside the records around it, which the live run renders as
|
|
1202
|
+
// one message: the container joins the assistant record ahead of it, and
|
|
1203
|
+
// what follows a container joins it until the turn carries text, where a
|
|
1204
|
+
// further text record opens a message of its own as it does live.
|
|
1205
|
+
const folded: CoreThreadMessageLike[] = [];
|
|
1206
|
+
for (const message of converted) {
|
|
1207
|
+
const previous = folded[folded.length - 1];
|
|
1208
|
+
if (
|
|
1209
|
+
previous !== undefined &&
|
|
1210
|
+
(isSyntheticToolCallContainer(message)
|
|
1211
|
+
? opensTurn(previous)
|
|
1212
|
+
: isSyntheticToolCallContainer(previous) &&
|
|
1213
|
+
answersToolCallContainer(message))
|
|
1214
|
+
) {
|
|
1215
|
+
folded[folded.length - 1] = foldTurnRecords(previous, message);
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
folded.push(message);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
for (let i = 0; i < folded.length; i++) {
|
|
1222
|
+
const message = folded[i]!;
|
|
981
1223
|
if (message.role !== "assistant") continue;
|
|
982
1224
|
|
|
983
1225
|
const hasInterrupt =
|
|
@@ -992,7 +1234,7 @@ export function fromAgUiMessages(
|
|
|
992
1234
|
);
|
|
993
1235
|
|
|
994
1236
|
if (hasInterrupt || hasPendingToolCall) {
|
|
995
|
-
|
|
1237
|
+
folded[i] = {
|
|
996
1238
|
...message,
|
|
997
1239
|
status: getAutoStatus(
|
|
998
1240
|
false,
|
|
@@ -1005,7 +1247,7 @@ export function fromAgUiMessages(
|
|
|
1005
1247
|
}
|
|
1006
1248
|
}
|
|
1007
1249
|
|
|
1008
|
-
return
|
|
1250
|
+
return folded;
|
|
1009
1251
|
}
|
|
1010
1252
|
|
|
1011
1253
|
function convertAssistantMessage(
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import {
|
|
14
14
|
applyA2uiOperations,
|
|
15
15
|
convertSurfaceToUISpec,
|
|
16
|
+
surfaceToOperations,
|
|
16
17
|
type A2uiState,
|
|
17
18
|
type A2uiSurfaceState,
|
|
18
19
|
} from "@assistant-ui/react-generative-ui/a2ui";
|
|
@@ -49,7 +50,7 @@ type PartOrderEntry =
|
|
|
49
50
|
| { kind: "text"; key: string; subagentRunId?: string }
|
|
50
51
|
| { kind: "reasoning"; key: string; subagentRunId?: string }
|
|
51
52
|
| { kind: "tool-call"; toolCallId: string }
|
|
52
|
-
| { kind: "data"; name: string; value: unknown };
|
|
53
|
+
| { kind: "data"; name: string; value: unknown; subagentRunId?: string };
|
|
53
54
|
|
|
54
55
|
type BuildContext = {
|
|
55
56
|
subagentsByParentToolCallId: Map<string, string[]>;
|
|
@@ -95,6 +96,7 @@ type ToolCallState = {
|
|
|
95
96
|
toolMessageId?: string;
|
|
96
97
|
mcpAppResourceUri?: string;
|
|
97
98
|
mcpAppServerId?: string;
|
|
99
|
+
artifact?: unknown;
|
|
98
100
|
modelContent?: ToolModelContentPart[];
|
|
99
101
|
snapshotResultApplied: boolean;
|
|
100
102
|
subagentRunId?: string;
|
|
@@ -246,6 +248,10 @@ export class RunAggregator {
|
|
|
246
248
|
private readonly toolCalls = new Map<string, ToolCallState>();
|
|
247
249
|
private readonly a2uiBuckets = new Map<string, A2uiState>();
|
|
248
250
|
private readonly a2uiToolCallIds = new Set<string>();
|
|
251
|
+
private readonly activityParts = new Map<
|
|
252
|
+
string,
|
|
253
|
+
{ kind: "data"; name: string; value: unknown; subagentRunId?: string }
|
|
254
|
+
>();
|
|
249
255
|
private readonly lastResolvedToolCallIdByScope = new Map<string, string>();
|
|
250
256
|
private readonly partOrder: PartOrderEntry[] = [];
|
|
251
257
|
private textPartCounter = 0;
|
|
@@ -319,6 +325,9 @@ export class RunAggregator {
|
|
|
319
325
|
break;
|
|
320
326
|
}
|
|
321
327
|
case "RUN_CANCELLED": {
|
|
328
|
+
// A cancel can land after RUN_FINISHED while the response is still
|
|
329
|
+
// closing, and the answer it finished is already persisted as complete.
|
|
330
|
+
if (this.status?.type === "complete") break;
|
|
322
331
|
this.status = { type: "incomplete", reason: "cancelled" };
|
|
323
332
|
this.closeOpenSubagentRuns(this.status);
|
|
324
333
|
this.emit();
|
|
@@ -503,7 +512,10 @@ export class RunAggregator {
|
|
|
503
512
|
this.handleA2uiActivitySnapshot(event);
|
|
504
513
|
break;
|
|
505
514
|
}
|
|
506
|
-
if (event.activityType !== MCP_APPS_ACTIVITY_TYPE)
|
|
515
|
+
if (event.activityType !== MCP_APPS_ACTIVITY_TYPE) {
|
|
516
|
+
this.handleActivitySnapshot(event);
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
507
519
|
const activityScope = this.scopeOf(event);
|
|
508
520
|
const toolCallId = event.content.toolCallId;
|
|
509
521
|
const fallbackId =
|
|
@@ -644,6 +656,42 @@ export class RunAggregator {
|
|
|
644
656
|
this.emit();
|
|
645
657
|
}
|
|
646
658
|
|
|
659
|
+
private handleActivitySnapshot(
|
|
660
|
+
event: Extract<AgUiEvent, { type: "ACTIVITY_SNAPSHOT" }>,
|
|
661
|
+
): void {
|
|
662
|
+
const scope = this.scopeOf(event);
|
|
663
|
+
const key = this.partKey(
|
|
664
|
+
scope,
|
|
665
|
+
event.messageId !== undefined
|
|
666
|
+
? `message:${event.messageId}`
|
|
667
|
+
: `type:${event.activityType}`,
|
|
668
|
+
);
|
|
669
|
+
const existing = this.activityParts.get(key);
|
|
670
|
+
if (existing) {
|
|
671
|
+
if (event.replace === false) return;
|
|
672
|
+
existing.name = `agui-activity/${event.activityType}`;
|
|
673
|
+
existing.value = event.content;
|
|
674
|
+
} else {
|
|
675
|
+
const part =
|
|
676
|
+
scope === ROOT_SCOPE
|
|
677
|
+
? {
|
|
678
|
+
kind: "data" as const,
|
|
679
|
+
name: `agui-activity/${event.activityType}`,
|
|
680
|
+
value: event.content,
|
|
681
|
+
}
|
|
682
|
+
: {
|
|
683
|
+
kind: "data" as const,
|
|
684
|
+
name: `agui-activity/${event.activityType}`,
|
|
685
|
+
value: event.content,
|
|
686
|
+
subagentRunId: scope,
|
|
687
|
+
};
|
|
688
|
+
this.activityParts.set(key, part);
|
|
689
|
+
this.partOrder.push(part);
|
|
690
|
+
this.activeTextMessageIdByScope.delete(scope);
|
|
691
|
+
}
|
|
692
|
+
this.emit();
|
|
693
|
+
}
|
|
694
|
+
|
|
647
695
|
private synthesizeA2uiToolCalls(): void {
|
|
648
696
|
const surfaces = new Map<string, A2uiSurfaceState>();
|
|
649
697
|
|
|
@@ -671,6 +719,7 @@ export class RunAggregator {
|
|
|
671
719
|
parsedArgs: spec,
|
|
672
720
|
result: {},
|
|
673
721
|
isError: undefined,
|
|
722
|
+
artifact: { a2ui: surfaceToOperations(surface) },
|
|
674
723
|
snapshotResultApplied: false,
|
|
675
724
|
};
|
|
676
725
|
if (!this.toolCalls.has(toolCallId)) {
|
|
@@ -729,6 +778,7 @@ export class RunAggregator {
|
|
|
729
778
|
this.toolCalls.clear();
|
|
730
779
|
this.a2uiBuckets.clear();
|
|
731
780
|
this.a2uiToolCallIds.clear();
|
|
781
|
+
this.activityParts.clear();
|
|
732
782
|
this.lastResolvedToolCallIdByScope.clear();
|
|
733
783
|
this.partOrder.length = 0;
|
|
734
784
|
this.textPartCounter = 0;
|
|
@@ -1097,6 +1147,7 @@ export class RunAggregator {
|
|
|
1097
1147
|
argsText: entry.argsText,
|
|
1098
1148
|
...(approval ? { approval } : {}),
|
|
1099
1149
|
...(entry.result !== undefined ? { result: entry.result } : {}),
|
|
1150
|
+
...(entry.artifact !== undefined ? { artifact: entry.artifact } : {}),
|
|
1100
1151
|
...(entry.modelContent !== undefined
|
|
1101
1152
|
? { modelContent: entry.modelContent }
|
|
1102
1153
|
: {}),
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
4
|
-
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
5
|
-
import type {
|
|
4
|
+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
5
|
+
import type {
|
|
6
|
+
ThreadAssistantMessage,
|
|
7
|
+
ThreadHistoryAdapter,
|
|
8
|
+
} from "@assistant-ui/core";
|
|
6
9
|
import type { HttpAgent } from "@ag-ui/client";
|
|
7
10
|
import { useAgUiRuntime } from "./useAgUiRuntime";
|
|
8
11
|
|
|
@@ -73,4 +76,92 @@ describe("useAgUiRuntime history", () => {
|
|
|
73
76
|
rerender();
|
|
74
77
|
expect(history.load).toHaveBeenCalledOnce();
|
|
75
78
|
});
|
|
79
|
+
|
|
80
|
+
it("records a settled tool interaction through the message part runtime", async () => {
|
|
81
|
+
const assistant: ThreadAssistantMessage = {
|
|
82
|
+
id: "assistant-1",
|
|
83
|
+
role: "assistant",
|
|
84
|
+
createdAt: new Date(0),
|
|
85
|
+
status: { type: "complete", reason: "unknown" },
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: "tool-call",
|
|
89
|
+
toolCallId: "call-1",
|
|
90
|
+
toolName: "present",
|
|
91
|
+
args: {},
|
|
92
|
+
argsText: "{}",
|
|
93
|
+
result: {},
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
metadata: {
|
|
97
|
+
unstable_state: null,
|
|
98
|
+
unstable_annotations: [],
|
|
99
|
+
unstable_data: [],
|
|
100
|
+
steps: [],
|
|
101
|
+
custom: {},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const update = vi.fn(async () => {});
|
|
105
|
+
const history: ThreadHistoryAdapter = {
|
|
106
|
+
load: vi.fn().mockResolvedValue({
|
|
107
|
+
headId: assistant.id,
|
|
108
|
+
messages: [{ parentId: null, message: assistant }],
|
|
109
|
+
}),
|
|
110
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
111
|
+
update,
|
|
112
|
+
};
|
|
113
|
+
const { result } = renderHook(() =>
|
|
114
|
+
useAgUiRuntime({ agent, adapters: { history } }),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
await waitFor(() =>
|
|
118
|
+
expect(
|
|
119
|
+
result.current.thread.getState().messages.map((message) => message.id),
|
|
120
|
+
).toEqual([assistant.id]),
|
|
121
|
+
);
|
|
122
|
+
const part = result.current.thread
|
|
123
|
+
.getMessageById(assistant.id)
|
|
124
|
+
.getMessagePartByToolCallId("call-1");
|
|
125
|
+
const recordInteraction = part.unstable_recordInteraction;
|
|
126
|
+
expect(recordInteraction).toBeDefined();
|
|
127
|
+
|
|
128
|
+
await act(async () => {
|
|
129
|
+
await recordInteraction!({
|
|
130
|
+
type: "action",
|
|
131
|
+
payload: { action: "confirm" },
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(part.getState()).toMatchObject({
|
|
136
|
+
unstable_interactions: {
|
|
137
|
+
entries: [
|
|
138
|
+
{
|
|
139
|
+
type: "action",
|
|
140
|
+
payload: { action: "confirm" },
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
expect(update).toHaveBeenCalledWith(
|
|
146
|
+
expect.objectContaining({
|
|
147
|
+
parentId: null,
|
|
148
|
+
message: expect.objectContaining({
|
|
149
|
+
id: assistant.id,
|
|
150
|
+
content: [
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
toolCallId: "call-1",
|
|
153
|
+
unstable_interactions: expect.objectContaining({
|
|
154
|
+
entries: [
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
type: "action",
|
|
157
|
+
payload: { action: "confirm" },
|
|
158
|
+
}),
|
|
159
|
+
],
|
|
160
|
+
}),
|
|
161
|
+
}),
|
|
162
|
+
],
|
|
163
|
+
}),
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
76
167
|
});
|