@assistant-ui/react-ag-ui 0.0.60 → 0.0.62
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 +1 -3
- package/dist/agUiExtras.d.ts.map +1 -1
- package/dist/hooks.d.ts +3 -5
- 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 +152 -144
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +141 -23
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +51 -53
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +65 -21
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +89 -88
- 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 +107 -108
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +3 -4
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
- package/dist/runtime/event-parser.d.ts +3 -5
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/interrupt-internals.d.ts +1 -3
- package/dist/runtime/interrupt-internals.d.ts.map +1 -1
- package/dist/runtime/logger.d.ts +2 -4
- package/dist/runtime/logger.d.ts.map +1 -1
- package/dist/runtime/mcp-tool-result.d.ts +4 -6
- package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
- package/dist/runtime/types.d.ts +237 -238
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.d.ts +13 -15
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +9 -2
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/runtime/AgUiThreadRuntimeCore.ts +239 -32
- package/src/runtime/adapter/conversions.content.test.ts +56 -0
- package/src/runtime/adapter/conversions.ts +112 -44
- 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
|
@@ -20,12 +20,14 @@ import type { ReadonlyJSONObject } from "assistant-stream/utils";
|
|
|
20
20
|
import {
|
|
21
21
|
AG_UI_METADATA_NAMESPACE,
|
|
22
22
|
A2UI_SURFACE_ACTIVITY_TYPE,
|
|
23
|
+
MCP_APPS_ACTIVITY_TYPE,
|
|
23
24
|
type AgUiCustomMetadata,
|
|
24
25
|
type AgUiOpaqueReasoning,
|
|
25
26
|
} from "./run-aggregator";
|
|
26
27
|
import {
|
|
27
28
|
applyA2uiOperations,
|
|
28
29
|
convertSurfaceToUISpec,
|
|
30
|
+
surfaceToOperations,
|
|
29
31
|
type A2uiState,
|
|
30
32
|
type A2uiSurfaceState,
|
|
31
33
|
} from "@assistant-ui/react-generative-ui/a2ui";
|
|
@@ -104,6 +106,7 @@ type ToolCallPart = {
|
|
|
104
106
|
argsText?: string;
|
|
105
107
|
args?: ReadonlyJSONObject;
|
|
106
108
|
result?: unknown;
|
|
109
|
+
artifact?: unknown;
|
|
107
110
|
isError?: boolean;
|
|
108
111
|
modelContent?: readonly ToolModelContentPart[];
|
|
109
112
|
unstable_toolMessageId?: string;
|
|
@@ -241,7 +244,9 @@ function mediaTypeForMime(mimeType: string | undefined): MediaInputType {
|
|
|
241
244
|
// Build an AG-UI multimodal source from a data URL, raw base64 payload, or an
|
|
242
245
|
// http(s) URL. A `url` source may omit the mime type; a `data` source always
|
|
243
246
|
// resolves one (falling back to application/octet-stream). An explicit
|
|
244
|
-
// `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.
|
|
245
250
|
function buildInputSource(
|
|
246
251
|
value: string,
|
|
247
252
|
declaredMimeType: string | undefined,
|
|
@@ -250,7 +255,8 @@ function buildInputSource(
|
|
|
250
255
|
const source = resolveFilePartSource({
|
|
251
256
|
data: value,
|
|
252
257
|
mimeType: declaredMimeType ?? "application/octet-stream",
|
|
253
|
-
sourceType
|
|
258
|
+
sourceType:
|
|
259
|
+
/^data:/i.test(value) && !parseDataUrl(value) ? "url" : sourceType,
|
|
254
260
|
});
|
|
255
261
|
if (source.kind === "url") {
|
|
256
262
|
return declaredMimeType !== undefined
|
|
@@ -718,6 +724,7 @@ function attachA2uiSurfaces(
|
|
|
718
724
|
args: spec as unknown as ReadonlyJSONObject,
|
|
719
725
|
argsText: JSON.stringify(spec),
|
|
720
726
|
result: {},
|
|
727
|
+
artifact: { a2ui: surfaceToOperations(surface) },
|
|
721
728
|
});
|
|
722
729
|
}
|
|
723
730
|
|
|
@@ -737,9 +744,9 @@ function attachA2uiSurfaces(
|
|
|
737
744
|
// A tool call that arrives without a `parentMessageId` has no record to join, so
|
|
738
745
|
// the client opens one keyed by the call id. An assistant record whose own id is
|
|
739
746
|
// a call it carries is that container: an id the client invented for the call
|
|
740
|
-
// rather than a boundary an agent drew. The prose the
|
|
741
|
-
//
|
|
742
|
-
// produced them.
|
|
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.
|
|
743
750
|
function isSyntheticToolCallContainer(message: CoreThreadMessageLike): boolean {
|
|
744
751
|
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
745
752
|
return false;
|
|
@@ -759,23 +766,37 @@ function isSyntheticToolCallContainer(message: CoreThreadMessageLike): boolean {
|
|
|
759
766
|
return carriesOwnId;
|
|
760
767
|
}
|
|
761
768
|
|
|
762
|
-
function
|
|
769
|
+
function carriesPart(message: CoreThreadMessageLike, type: string): boolean {
|
|
763
770
|
return (
|
|
764
771
|
Array.isArray(message.content) &&
|
|
765
|
-
message.content.some((part) => isObject(part) && part.type ===
|
|
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"))
|
|
766
788
|
);
|
|
767
789
|
}
|
|
768
790
|
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
function
|
|
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 {
|
|
773
795
|
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
774
796
|
return false;
|
|
775
797
|
if (message.content.length === 0) return false;
|
|
776
|
-
return (
|
|
777
|
-
|
|
778
|
-
!message.content.some((part) => isObject(part) && part.type === "tool-call")
|
|
798
|
+
return !message.content.some(
|
|
799
|
+
(part) => isObject(part) && part.type === "tool-call",
|
|
779
800
|
);
|
|
780
801
|
}
|
|
781
802
|
|
|
@@ -789,51 +810,53 @@ function readNamespacedMetadata(metadata: unknown): Record<string, unknown> {
|
|
|
789
810
|
return isObject(namespaced) ? namespaced : {};
|
|
790
811
|
}
|
|
791
812
|
|
|
792
|
-
function
|
|
793
|
-
|
|
813
|
+
function foldTurnRecords(
|
|
814
|
+
previous: CoreThreadMessageLike,
|
|
794
815
|
message: CoreThreadMessageLike,
|
|
795
816
|
): CoreThreadMessageLike {
|
|
796
817
|
const interrupts = [
|
|
797
|
-
...(readPersistedInterrupts(
|
|
818
|
+
...(readPersistedInterrupts(previous.metadata) ?? []),
|
|
798
819
|
...(readPersistedInterrupts(message.metadata) ?? []),
|
|
799
820
|
];
|
|
800
|
-
// An entry that rode the
|
|
801
|
-
//
|
|
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
|
|
802
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");
|
|
803
827
|
const opaqueReasoning = [
|
|
804
|
-
...readOpaqueReasoning(
|
|
805
|
-
...readOpaqueReasoning(message.metadata).map((entry) =>
|
|
806
|
-
...entry,
|
|
807
|
-
|
|
808
|
-
})),
|
|
828
|
+
...readOpaqueReasoning(previous.metadata),
|
|
829
|
+
...readOpaqueReasoning(message.metadata).map((entry) =>
|
|
830
|
+
trailsCalls ? { ...entry, after: true } : entry,
|
|
831
|
+
),
|
|
809
832
|
];
|
|
810
833
|
const namespaced = {
|
|
811
|
-
...readNamespacedMetadata(
|
|
834
|
+
...readNamespacedMetadata(previous.metadata),
|
|
812
835
|
...readNamespacedMetadata(message.metadata),
|
|
813
836
|
...(interrupts.length > 0 ? { interrupts } : {}),
|
|
814
837
|
...(opaqueReasoning.length > 0 ? { opaqueReasoning } : {}),
|
|
815
838
|
} satisfies AgUiCustomMetadata & Record<string, unknown>;
|
|
816
839
|
const custom = {
|
|
817
|
-
...readCustomMetadata(
|
|
840
|
+
...readCustomMetadata(previous.metadata),
|
|
818
841
|
...readCustomMetadata(message.metadata),
|
|
819
842
|
...(Object.keys(namespaced).length > 0
|
|
820
843
|
? { [AG_UI_METADATA_NAMESPACE]: namespaced }
|
|
821
844
|
: {}),
|
|
822
845
|
};
|
|
823
846
|
const metadata = {
|
|
824
|
-
...(isObject(
|
|
847
|
+
...(isObject(previous.metadata) ? previous.metadata : {}),
|
|
825
848
|
...(isObject(message.metadata) ? message.metadata : {}),
|
|
826
849
|
...(Object.keys(custom).length > 0 ? { custom } : {}),
|
|
827
850
|
};
|
|
828
851
|
return {
|
|
829
|
-
...
|
|
852
|
+
...previous,
|
|
830
853
|
...message,
|
|
831
|
-
//
|
|
854
|
+
// A container's id names a call rather than the turn, so the record the
|
|
832
855
|
// agent addressed its prose to wins: that is the id the next run has to
|
|
833
856
|
// carry for the agent to recognize the message it wrote.
|
|
834
|
-
id: carriesText(message) ? message.id :
|
|
857
|
+
id: carriesText(message) ? message.id : previous.id,
|
|
835
858
|
content: [
|
|
836
|
-
...(Array.isArray(
|
|
859
|
+
...(Array.isArray(previous.content) ? previous.content : []),
|
|
837
860
|
...(Array.isArray(message.content) ? message.content : []),
|
|
838
861
|
],
|
|
839
862
|
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
|
@@ -856,6 +879,10 @@ export function fromAgUiMessages(
|
|
|
856
879
|
const converted: CoreThreadMessageLike[] = [];
|
|
857
880
|
const a2uiBuckets = new Map<string, A2uiState>();
|
|
858
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
|
+
>();
|
|
859
886
|
// A zero-data-retention run carries its payload in encryptedValue with no
|
|
860
887
|
// readable content, so the record has no part to become and rides on the
|
|
861
888
|
// neighbouring message instead of being dropped. The anchor is the index the
|
|
@@ -1004,15 +1031,9 @@ export function fromAgUiMessages(
|
|
|
1004
1031
|
}
|
|
1005
1032
|
|
|
1006
1033
|
if (role === "activity") {
|
|
1007
|
-
// Only a2ui-surface activity messages have an assistant-part equivalent
|
|
1008
|
-
// to rehydrate; other activity types still have no surface to repaint.
|
|
1009
1034
|
const activityType = getString(rawMessage, "activityType");
|
|
1010
|
-
if (activityType
|
|
1011
|
-
|
|
1012
|
-
? (rawMessage.content as Record<string, unknown>)
|
|
1013
|
-
: null;
|
|
1014
|
-
const operations = activityContent?.["a2ui_operations"];
|
|
1015
|
-
if (!Array.isArray(operations)) continue;
|
|
1035
|
+
if (activityType === undefined || activityType === MCP_APPS_ACTIVITY_TYPE)
|
|
1036
|
+
continue;
|
|
1016
1037
|
|
|
1017
1038
|
// A surface belongs to the turn that painted it, and held reasoning is a
|
|
1018
1039
|
// nearer antecedent than the previous turn's assistant record, so it is
|
|
@@ -1030,6 +1051,48 @@ export function fromAgUiMessages(
|
|
|
1030
1051
|
if (ownerIndex === -1) continue;
|
|
1031
1052
|
|
|
1032
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;
|
|
1033
1096
|
const bucketKey = getString(rawMessage, "id") ?? "a2ui:anonymous";
|
|
1034
1097
|
const { state } = applyA2uiOperations(new Map(), operations);
|
|
1035
1098
|
a2uiBuckets.delete(bucketKey);
|
|
@@ -1134,17 +1197,22 @@ export function fromAgUiMessages(
|
|
|
1134
1197
|
]);
|
|
1135
1198
|
}
|
|
1136
1199
|
|
|
1137
|
-
// A turn that called a tool
|
|
1138
|
-
//
|
|
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.
|
|
1139
1205
|
const folded: CoreThreadMessageLike[] = [];
|
|
1140
1206
|
for (const message of converted) {
|
|
1141
1207
|
const previous = folded[folded.length - 1];
|
|
1142
1208
|
if (
|
|
1143
1209
|
previous !== undefined &&
|
|
1144
|
-
isSyntheticToolCallContainer(
|
|
1145
|
-
|
|
1210
|
+
(isSyntheticToolCallContainer(message)
|
|
1211
|
+
? opensTurn(previous)
|
|
1212
|
+
: isSyntheticToolCallContainer(previous) &&
|
|
1213
|
+
answersToolCallContainer(message))
|
|
1146
1214
|
) {
|
|
1147
|
-
folded[folded.length - 1] =
|
|
1215
|
+
folded[folded.length - 1] = foldTurnRecords(previous, message);
|
|
1148
1216
|
continue;
|
|
1149
1217
|
}
|
|
1150
1218
|
folded.push(message);
|
|
@@ -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
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { act, cleanup, renderHook } from "@testing-library/react";
|
|
5
|
+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
6
6
|
import type { HttpAgent } from "@ag-ui/client";
|
|
7
7
|
import type { ThreadMessage } from "@assistant-ui/core";
|
|
8
8
|
import { AgUiThreadRuntimeCore } from "./runtime/AgUiThreadRuntimeCore";
|
|
@@ -15,12 +15,16 @@ type ThreadLoad = Awaited<
|
|
|
15
15
|
|
|
16
16
|
function deferred<T>() {
|
|
17
17
|
let resolve!: (value: T) => void;
|
|
18
|
-
|
|
18
|
+
let reject!: (reason?: unknown) => void;
|
|
19
|
+
const promise = new Promise<T>((res, rej) => {
|
|
19
20
|
resolve = res;
|
|
21
|
+
reject = rej;
|
|
20
22
|
});
|
|
21
|
-
return { promise, resolve };
|
|
23
|
+
return { promise, resolve, reject };
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
type Subscriber = Record<string, ((payload: any) => void) | undefined>;
|
|
27
|
+
|
|
24
28
|
function message(id: string): ThreadMessage {
|
|
25
29
|
return {
|
|
26
30
|
id,
|
|
@@ -35,15 +39,17 @@ function message(id: string): ThreadMessage {
|
|
|
35
39
|
function renderRuntime(
|
|
36
40
|
load: (id: string) => Promise<ThreadLoad>,
|
|
37
41
|
create: () => Promise<void> = async () => {},
|
|
42
|
+
agentOverride?: HttpAgent,
|
|
38
43
|
) {
|
|
39
44
|
const agent = {
|
|
40
45
|
runAgent: vi.fn(),
|
|
41
46
|
abortRun: vi.fn(),
|
|
42
47
|
} as unknown as HttpAgent;
|
|
48
|
+
const runtimeAgent = agentOverride ?? agent;
|
|
43
49
|
return renderHook(() => {
|
|
44
50
|
const [threadId, setThreadId] = useState("initial");
|
|
45
51
|
return useAgUiRuntime({
|
|
46
|
-
agent,
|
|
52
|
+
agent: runtimeAgent,
|
|
47
53
|
adapters: {
|
|
48
54
|
threadList: {
|
|
49
55
|
threadId,
|
|
@@ -112,6 +118,80 @@ describe("useAgUiRuntime thread switching", () => {
|
|
|
112
118
|
},
|
|
113
119
|
);
|
|
114
120
|
|
|
121
|
+
it.each(["resolve", "reject"] as const)(
|
|
122
|
+
"keeps a new thread empty after an active run when creation %s",
|
|
123
|
+
async (outcome) => {
|
|
124
|
+
const load = deferred<ThreadLoad>();
|
|
125
|
+
const create = deferred<void>();
|
|
126
|
+
const run = deferred<void>();
|
|
127
|
+
let subscriber!: Subscriber;
|
|
128
|
+
const runAgent = vi.fn(async (_input: unknown, next: Subscriber) => {
|
|
129
|
+
subscriber = next;
|
|
130
|
+
await run.promise;
|
|
131
|
+
});
|
|
132
|
+
const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
|
|
133
|
+
const { result } = renderRuntime(
|
|
134
|
+
() => load.promise,
|
|
135
|
+
() => create.promise,
|
|
136
|
+
agent,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
let switchA!: Promise<void>;
|
|
140
|
+
act(() => {
|
|
141
|
+
switchA = result.current.threads.switchToThread("thread-a");
|
|
142
|
+
});
|
|
143
|
+
await act(async () => {
|
|
144
|
+
load.resolve({ messages: [message("thread-a")] });
|
|
145
|
+
await switchA;
|
|
146
|
+
});
|
|
147
|
+
expect(
|
|
148
|
+
result.current.thread.export().messages.map((m) => m.message.id),
|
|
149
|
+
).toEqual(["thread-a"]);
|
|
150
|
+
|
|
151
|
+
act(() => {
|
|
152
|
+
void result.current.thread.append("still running");
|
|
153
|
+
});
|
|
154
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
155
|
+
|
|
156
|
+
let switchNew!: Promise<void>;
|
|
157
|
+
act(() => {
|
|
158
|
+
switchNew = result.current.threads.switchToNewThread();
|
|
159
|
+
});
|
|
160
|
+
expect(result.current.thread.export().messages).toEqual([]);
|
|
161
|
+
await waitFor(() =>
|
|
162
|
+
expect(result.current.threads.getState().mainThreadId).toBe(
|
|
163
|
+
"thread-new",
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
if (outcome === "resolve") {
|
|
169
|
+
create.resolve();
|
|
170
|
+
await switchNew;
|
|
171
|
+
} else {
|
|
172
|
+
create.reject(new Error("create failed"));
|
|
173
|
+
await expect(switchNew).rejects.toThrow("create failed");
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
act(() => {
|
|
178
|
+
subscriber.onMessagesSnapshotEvent?.({
|
|
179
|
+
event: {
|
|
180
|
+
type: "MESSAGES_SNAPSHOT",
|
|
181
|
+
messages: [{ id: "late", role: "assistant", content: "too late" }],
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
expect(result.current.thread.export().messages).toEqual([]);
|
|
186
|
+
|
|
187
|
+
await act(async () => {
|
|
188
|
+
run.resolve();
|
|
189
|
+
await runAgent.mock.results[0]!.value;
|
|
190
|
+
});
|
|
191
|
+
expect(agent.abortRun).toHaveBeenCalledOnce();
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
115
195
|
it("ignores a load superseded by creating a new thread", async () => {
|
|
116
196
|
const resume = vi
|
|
117
197
|
.spyOn(AgUiThreadRuntimeCore.prototype, "resumeInFlightRun")
|