@assistant-ui/react-ag-ui 0.0.58 → 0.0.60
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 -2
- package/dist/agUiExtras.d.ts.map +1 -1
- package/dist/hooks.d.ts +6 -7
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts +17 -6
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +95 -47
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +11 -11
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +114 -17
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +9 -10
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +59 -0
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +2 -3
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +4 -5
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
- package/dist/runtime/event-parser.d.ts +2 -3
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/interrupt-internals.d.ts +3 -4
- package/dist/runtime/interrupt-internals.d.ts.map +1 -1
- package/dist/runtime/logger.d.ts +2 -3
- package/dist/runtime/logger.d.ts.map +1 -1
- package/dist/runtime/mcp-tool-result.d.ts +3 -4
- package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
- package/dist/runtime/types.d.ts +35 -11
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.d.ts +2 -3
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +14 -5
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +12 -11
- package/src/index.ts +1 -0
- package/src/runtime/AgUiThreadRuntimeCore.test.ts +18 -0
- package/src/runtime/AgUiThreadRuntimeCore.ts +176 -63
- package/src/runtime/adapter/conversions.ts +200 -26
- package/src/runtime/adapter/run-aggregator.ts +85 -0
- package/src/runtime/types.ts +26 -0
- package/src/useAgUiRuntime.queue.test.tsx +54 -0
- package/src/useAgUiRuntime.thread-switch.test.tsx +199 -0
- package/src/useAgUiRuntime.ts +14 -3
|
@@ -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,
|
|
@@ -44,14 +45,14 @@ type AttachmentLike = {
|
|
|
44
45
|
};
|
|
45
46
|
|
|
46
47
|
type ThreadMessageLike = {
|
|
47
|
-
id?: string;
|
|
48
|
+
id?: string | undefined;
|
|
48
49
|
role: string;
|
|
49
50
|
content: unknown;
|
|
50
51
|
metadata?: unknown;
|
|
51
|
-
name?: string;
|
|
52
|
-
toolCallId?: string;
|
|
53
|
-
error?: string;
|
|
54
|
-
attachments?: readonly AttachmentLike[];
|
|
52
|
+
name?: string | undefined;
|
|
53
|
+
toolCallId?: string | undefined;
|
|
54
|
+
error?: string | undefined;
|
|
55
|
+
attachments?: readonly AttachmentLike[] | undefined;
|
|
55
56
|
};
|
|
56
57
|
|
|
57
58
|
type NormalizedThreadMessageLike = ThreadMessageLike & { id: string };
|
|
@@ -733,6 +734,112 @@ function attachA2uiSurfaces(
|
|
|
733
734
|
return { ...message, content: [...preserved, ...a2uiParts] };
|
|
734
735
|
}
|
|
735
736
|
|
|
737
|
+
// A tool call that arrives without a `parentMessageId` has no record to join, so
|
|
738
|
+
// the client opens one keyed by the call id. An assistant record whose own id is
|
|
739
|
+
// 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 run answers with lands in
|
|
741
|
+
// a record of its own, and nothing on the wire binds the two to the run that
|
|
742
|
+
// produced them.
|
|
743
|
+
function isSyntheticToolCallContainer(message: CoreThreadMessageLike): boolean {
|
|
744
|
+
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
745
|
+
return false;
|
|
746
|
+
let carriesOwnId = false;
|
|
747
|
+
for (const part of message.content) {
|
|
748
|
+
if (!isObject(part)) continue;
|
|
749
|
+
// The container is opened empty, so text on the record means an agent
|
|
750
|
+
// addressed it and the id is one it chose.
|
|
751
|
+
if (part.type === "text") return false;
|
|
752
|
+
if (
|
|
753
|
+
part.type === "tool-call" &&
|
|
754
|
+
getString(part, "toolCallId") === message.id
|
|
755
|
+
) {
|
|
756
|
+
carriesOwnId = true;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return carriesOwnId;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function carriesText(message: CoreThreadMessageLike): boolean {
|
|
763
|
+
return (
|
|
764
|
+
Array.isArray(message.content) &&
|
|
765
|
+
message.content.some((part) => isObject(part) && part.type === "text")
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// What the run put after the container folds onto it: another container for a
|
|
770
|
+
// parallel call, or the parts of the answer. A record carrying a tool call an
|
|
771
|
+
// agent addressed is a boundary of its own and keeps its message.
|
|
772
|
+
function foldsOntoToolCallContainer(message: CoreThreadMessageLike): boolean {
|
|
773
|
+
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
774
|
+
return false;
|
|
775
|
+
if (message.content.length === 0) return false;
|
|
776
|
+
return (
|
|
777
|
+
isSyntheticToolCallContainer(message) ||
|
|
778
|
+
!message.content.some((part) => isObject(part) && part.type === "tool-call")
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function readCustomMetadata(metadata: unknown): Record<string, unknown> {
|
|
783
|
+
if (!isObject(metadata) || !isObject(metadata.custom)) return {};
|
|
784
|
+
return metadata.custom;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function readNamespacedMetadata(metadata: unknown): Record<string, unknown> {
|
|
788
|
+
const namespaced = readCustomMetadata(metadata)[AG_UI_METADATA_NAMESPACE];
|
|
789
|
+
return isObject(namespaced) ? namespaced : {};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function foldOntoToolCallContainer(
|
|
793
|
+
container: CoreThreadMessageLike,
|
|
794
|
+
message: CoreThreadMessageLike,
|
|
795
|
+
): CoreThreadMessageLike {
|
|
796
|
+
const interrupts = [
|
|
797
|
+
...(readPersistedInterrupts(container.metadata) ?? []),
|
|
798
|
+
...(readPersistedInterrupts(message.metadata) ?? []),
|
|
799
|
+
];
|
|
800
|
+
// An entry that rode the folded record sat ahead of content that now follows
|
|
801
|
+
// the container's calls, so it is replayed after the merged record and its
|
|
802
|
+
// tool results rather than ahead of a message that no longer starts there.
|
|
803
|
+
const opaqueReasoning = [
|
|
804
|
+
...readOpaqueReasoning(container.metadata),
|
|
805
|
+
...readOpaqueReasoning(message.metadata).map((entry) => ({
|
|
806
|
+
...entry,
|
|
807
|
+
after: true,
|
|
808
|
+
})),
|
|
809
|
+
];
|
|
810
|
+
const namespaced = {
|
|
811
|
+
...readNamespacedMetadata(container.metadata),
|
|
812
|
+
...readNamespacedMetadata(message.metadata),
|
|
813
|
+
...(interrupts.length > 0 ? { interrupts } : {}),
|
|
814
|
+
...(opaqueReasoning.length > 0 ? { opaqueReasoning } : {}),
|
|
815
|
+
} satisfies AgUiCustomMetadata & Record<string, unknown>;
|
|
816
|
+
const custom = {
|
|
817
|
+
...readCustomMetadata(container.metadata),
|
|
818
|
+
...readCustomMetadata(message.metadata),
|
|
819
|
+
...(Object.keys(namespaced).length > 0
|
|
820
|
+
? { [AG_UI_METADATA_NAMESPACE]: namespaced }
|
|
821
|
+
: {}),
|
|
822
|
+
};
|
|
823
|
+
const metadata = {
|
|
824
|
+
...(isObject(container.metadata) ? container.metadata : {}),
|
|
825
|
+
...(isObject(message.metadata) ? message.metadata : {}),
|
|
826
|
+
...(Object.keys(custom).length > 0 ? { custom } : {}),
|
|
827
|
+
};
|
|
828
|
+
return {
|
|
829
|
+
...container,
|
|
830
|
+
...message,
|
|
831
|
+
// The container's id names a call rather than the turn, so the record the
|
|
832
|
+
// agent addressed its prose to wins: that is the id the next run has to
|
|
833
|
+
// carry for the agent to recognize the message it wrote.
|
|
834
|
+
id: carriesText(message) ? message.id : container.id,
|
|
835
|
+
content: [
|
|
836
|
+
...(Array.isArray(container.content) ? container.content : []),
|
|
837
|
+
...(Array.isArray(message.content) ? message.content : []),
|
|
838
|
+
],
|
|
839
|
+
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
|
840
|
+
} as CoreThreadMessageLike;
|
|
841
|
+
}
|
|
842
|
+
|
|
736
843
|
export type FromAgUiMessagesOptions = {
|
|
737
844
|
/**
|
|
738
845
|
* Whether to convert `reasoning` messages into visible reasoning parts.
|
|
@@ -754,6 +861,40 @@ export function fromAgUiMessages(
|
|
|
754
861
|
// neighbouring message instead of being dropped. The anchor is the index the
|
|
755
862
|
// next pushed message will occupy, which is where it sat on the wire.
|
|
756
863
|
const opaqueReasoning: (AgUiOpaqueReasoning & { anchor: number })[] = [];
|
|
864
|
+
// A reasoning record belongs to the assistant record that follows it, which is
|
|
865
|
+
// the binding the export writes and the one the reference integrations read
|
|
866
|
+
// back. Holding it until that assistant arrives rebuilds the pair as the
|
|
867
|
+
// single message the live run produced.
|
|
868
|
+
const pendingReasoning: {
|
|
869
|
+
id: string | undefined;
|
|
870
|
+
part: ReasoningMessagePart;
|
|
871
|
+
}[] = [];
|
|
872
|
+
|
|
873
|
+
const flushPendingReasoning = () => {
|
|
874
|
+
for (const { id, part } of pendingReasoning) {
|
|
875
|
+
converted.push({
|
|
876
|
+
id: id ?? generateId(),
|
|
877
|
+
role: "assistant",
|
|
878
|
+
content: [part],
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
pendingReasoning.length = 0;
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
const withPendingReasoning = (
|
|
885
|
+
message: CoreThreadMessageLike,
|
|
886
|
+
): CoreThreadMessageLike => {
|
|
887
|
+
if (pendingReasoning.length === 0) return message;
|
|
888
|
+
const parts = pendingReasoning.map(({ part }) => part);
|
|
889
|
+
pendingReasoning.length = 0;
|
|
890
|
+
return {
|
|
891
|
+
...message,
|
|
892
|
+
content: [
|
|
893
|
+
...parts,
|
|
894
|
+
...(Array.isArray(message.content) ? message.content : []),
|
|
895
|
+
],
|
|
896
|
+
};
|
|
897
|
+
};
|
|
757
898
|
|
|
758
899
|
for (const rawMessage of messages) {
|
|
759
900
|
if (!isObject(rawMessage)) continue;
|
|
@@ -761,6 +902,7 @@ export function fromAgUiMessages(
|
|
|
761
902
|
if (!role) continue;
|
|
762
903
|
|
|
763
904
|
if (role === "tool") {
|
|
905
|
+
flushPendingReasoning();
|
|
764
906
|
const toolCallId = getToolCallId(rawMessage) ?? `tool-${generateId()}`;
|
|
765
907
|
const toolMessageId = getString(rawMessage, "id");
|
|
766
908
|
const modelContent = extractText(rawMessage.content);
|
|
@@ -872,6 +1014,11 @@ export function fromAgUiMessages(
|
|
|
872
1014
|
const operations = activityContent?.["a2ui_operations"];
|
|
873
1015
|
if (!Array.isArray(operations)) continue;
|
|
874
1016
|
|
|
1017
|
+
// A surface belongs to the turn that painted it, and held reasoning is a
|
|
1018
|
+
// nearer antecedent than the previous turn's assistant record, so it is
|
|
1019
|
+
// released here rather than folded past this record.
|
|
1020
|
+
flushPendingReasoning();
|
|
1021
|
+
|
|
875
1022
|
let ownerIndex = -1;
|
|
876
1023
|
for (let i = converted.length - 1; i >= 0; i--) {
|
|
877
1024
|
const candidate = converted[i];
|
|
@@ -903,7 +1050,9 @@ export function fromAgUiMessages(
|
|
|
903
1050
|
}
|
|
904
1051
|
|
|
905
1052
|
if (role === "assistant") {
|
|
906
|
-
converted.push(
|
|
1053
|
+
converted.push(
|
|
1054
|
+
withPendingReasoning(toAssistantSnapshotMessage(rawMessage)),
|
|
1055
|
+
);
|
|
907
1056
|
continue;
|
|
908
1057
|
}
|
|
909
1058
|
|
|
@@ -916,6 +1065,9 @@ export function fromAgUiMessages(
|
|
|
916
1065
|
// accept the next run.
|
|
917
1066
|
const opaqueId = getString(rawMessage, "id");
|
|
918
1067
|
if (opaqueId?.trim() && encryptedValue?.trim()) {
|
|
1068
|
+
// The anchor counts materialized messages, so anything still held
|
|
1069
|
+
// takes its own slot rather than folding past this record.
|
|
1070
|
+
flushPendingReasoning();
|
|
919
1071
|
opaqueReasoning.push({
|
|
920
1072
|
id: opaqueId,
|
|
921
1073
|
encryptedValue,
|
|
@@ -939,31 +1091,37 @@ export function fromAgUiMessages(
|
|
|
939
1091
|
}
|
|
940
1092
|
continue;
|
|
941
1093
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1094
|
+
const rawReasoningId = getString(rawMessage, "id");
|
|
1095
|
+
// A blank id still wins over a synthesized one on export, which would put
|
|
1096
|
+
// an unaddressable record on the wire.
|
|
1097
|
+
const reasoningId = rawReasoningId?.trim() ? rawReasoningId : undefined;
|
|
1098
|
+
// The fold costs the record its own message id, so it rides the part
|
|
1099
|
+
// instead: the export re-emits each block under the id it arrived with.
|
|
1100
|
+
const meta = {
|
|
1101
|
+
...(reasoningId !== undefined ? { reasoningId } : {}),
|
|
1102
|
+
...(encryptedValue !== undefined ? { encryptedValue } : {}),
|
|
1103
|
+
};
|
|
1104
|
+
pendingReasoning.push({
|
|
1105
|
+
id: reasoningId,
|
|
1106
|
+
part: {
|
|
1107
|
+
type: "reasoning",
|
|
1108
|
+
text,
|
|
1109
|
+
...(Object.keys(meta).length > 0
|
|
1110
|
+
? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
|
|
1111
|
+
: {}),
|
|
1112
|
+
},
|
|
958
1113
|
});
|
|
959
1114
|
continue;
|
|
960
1115
|
}
|
|
961
1116
|
|
|
962
1117
|
if (role === "user" || role === "system" || role === "developer") {
|
|
1118
|
+
flushPendingReasoning();
|
|
963
1119
|
converted.push(toUserOrSystemSnapshotMessage(role, rawMessage));
|
|
964
1120
|
}
|
|
965
1121
|
}
|
|
966
1122
|
|
|
1123
|
+
flushPendingReasoning();
|
|
1124
|
+
|
|
967
1125
|
for (const { anchor, ...entry } of opaqueReasoning) {
|
|
968
1126
|
// Nothing followed it on the wire, so it trails the last message instead.
|
|
969
1127
|
const trailing = anchor >= converted.length;
|
|
@@ -976,8 +1134,24 @@ export function fromAgUiMessages(
|
|
|
976
1134
|
]);
|
|
977
1135
|
}
|
|
978
1136
|
|
|
979
|
-
|
|
980
|
-
|
|
1137
|
+
// A turn that called a tool before answering sits on the wire as a container
|
|
1138
|
+
// and the records that follow it, which the live run renders as one message.
|
|
1139
|
+
const folded: CoreThreadMessageLike[] = [];
|
|
1140
|
+
for (const message of converted) {
|
|
1141
|
+
const previous = folded[folded.length - 1];
|
|
1142
|
+
if (
|
|
1143
|
+
previous !== undefined &&
|
|
1144
|
+
isSyntheticToolCallContainer(previous) &&
|
|
1145
|
+
foldsOntoToolCallContainer(message)
|
|
1146
|
+
) {
|
|
1147
|
+
folded[folded.length - 1] = foldOntoToolCallContainer(previous, message);
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
folded.push(message);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
for (let i = 0; i < folded.length; i++) {
|
|
1154
|
+
const message = folded[i]!;
|
|
981
1155
|
if (message.role !== "assistant") continue;
|
|
982
1156
|
|
|
983
1157
|
const hasInterrupt =
|
|
@@ -992,7 +1166,7 @@ export function fromAgUiMessages(
|
|
|
992
1166
|
);
|
|
993
1167
|
|
|
994
1168
|
if (hasInterrupt || hasPendingToolCall) {
|
|
995
|
-
|
|
1169
|
+
folded[i] = {
|
|
996
1170
|
...message,
|
|
997
1171
|
status: getAutoStatus(
|
|
998
1172
|
false,
|
|
@@ -1005,7 +1179,7 @@ export function fromAgUiMessages(
|
|
|
1005
1179
|
}
|
|
1006
1180
|
}
|
|
1007
1181
|
|
|
1008
|
-
return
|
|
1182
|
+
return folded;
|
|
1009
1183
|
}
|
|
1010
1184
|
|
|
1011
1185
|
function convertAssistantMessage(
|
|
@@ -87,6 +87,7 @@ type ToolCallState = {
|
|
|
87
87
|
toolCallId: string;
|
|
88
88
|
toolCallName: string;
|
|
89
89
|
argsText: string;
|
|
90
|
+
argsTextScanner?: JSONContainerScanner | undefined;
|
|
90
91
|
parsedArgs: Record<string, unknown> | undefined;
|
|
91
92
|
result: unknown;
|
|
92
93
|
isError: boolean | undefined;
|
|
@@ -99,6 +100,80 @@ type ToolCallState = {
|
|
|
99
100
|
subagentRunId?: string;
|
|
100
101
|
};
|
|
101
102
|
|
|
103
|
+
type JSONContainerScanner = {
|
|
104
|
+
state: "pending" | "open" | "complete" | "invalid";
|
|
105
|
+
stack: ("{" | "[")[];
|
|
106
|
+
inString: boolean;
|
|
107
|
+
escaped: boolean;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const createJSONContainerScanner = (): JSONContainerScanner => ({
|
|
111
|
+
state: "pending",
|
|
112
|
+
stack: [],
|
|
113
|
+
inString: false,
|
|
114
|
+
escaped: false,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const isJSONWhitespace = (char: string): boolean =>
|
|
118
|
+
char === " " || char === "\t" || char === "\n" || char === "\r";
|
|
119
|
+
|
|
120
|
+
const scanJSONContainerDelta = (
|
|
121
|
+
scanner: JSONContainerScanner,
|
|
122
|
+
delta: string,
|
|
123
|
+
): boolean => {
|
|
124
|
+
let completed = false;
|
|
125
|
+
|
|
126
|
+
for (const char of delta) {
|
|
127
|
+
if (scanner.state === "invalid") continue;
|
|
128
|
+
if (scanner.state === "complete") {
|
|
129
|
+
if (isJSONWhitespace(char)) continue;
|
|
130
|
+
scanner.state = "invalid";
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (scanner.state === "pending") {
|
|
134
|
+
if (isJSONWhitespace(char)) continue;
|
|
135
|
+
if (char !== "{" && char !== "[") {
|
|
136
|
+
scanner.state = "invalid";
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
scanner.state = "open";
|
|
140
|
+
scanner.stack.push(char);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (scanner.inString) {
|
|
144
|
+
if (scanner.escaped) {
|
|
145
|
+
scanner.escaped = false;
|
|
146
|
+
} else if (char === "\\") {
|
|
147
|
+
scanner.escaped = true;
|
|
148
|
+
} else if (char === '"') {
|
|
149
|
+
scanner.inString = false;
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (char === '"') {
|
|
154
|
+
scanner.inString = true;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (char === "{" || char === "[") {
|
|
158
|
+
scanner.stack.push(char);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (char === "}" || char === "]") {
|
|
162
|
+
const expected = char === "}" ? "{" : "[";
|
|
163
|
+
if (scanner.stack.pop() !== expected) {
|
|
164
|
+
scanner.state = "invalid";
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (scanner.stack.length === 0) {
|
|
168
|
+
scanner.state = "complete";
|
|
169
|
+
completed = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return completed && scanner.state === "complete";
|
|
175
|
+
};
|
|
176
|
+
|
|
102
177
|
export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
|
|
103
178
|
|
|
104
179
|
export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
|
|
@@ -816,7 +891,17 @@ export class RunAggregator {
|
|
|
816
891
|
private appendToolArgs(id: string | undefined, delta: string) {
|
|
817
892
|
const entry = id ? this.toolCalls.get(id) : undefined;
|
|
818
893
|
if (!entry) return;
|
|
894
|
+
if (!entry.argsTextScanner) {
|
|
895
|
+
entry.argsTextScanner = createJSONContainerScanner();
|
|
896
|
+
}
|
|
819
897
|
entry.argsText += delta;
|
|
898
|
+
const shouldParse = scanJSONContainerDelta(entry.argsTextScanner, delta);
|
|
899
|
+
if (!shouldParse) {
|
|
900
|
+
if (entry.argsTextScanner.state === "invalid") {
|
|
901
|
+
entry.parsedArgs = undefined;
|
|
902
|
+
}
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
820
905
|
try {
|
|
821
906
|
const parsed = JSON.parse(entry.argsText);
|
|
822
907
|
if (parsed && typeof parsed === "object") {
|
package/src/runtime/types.ts
CHANGED
|
@@ -56,10 +56,36 @@ export type UseAgUiRuntimeAdapters = {
|
|
|
56
56
|
threadList?: UseAgUiThreadListAdapter;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
export type AgUiResumeTranscript = "full" | "appended";
|
|
60
|
+
|
|
59
61
|
export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
|
|
60
62
|
agent: AbstractAgent;
|
|
61
63
|
logger?: Partial<Logger>;
|
|
62
64
|
showThinking?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* What `messages` carries on a resume run, meaning a `RunAgentInput` that
|
|
67
|
+
* also carries `resume`. The AG-UI interrupt spec leaves this undefined: its
|
|
68
|
+
* contract rules constrain the thread id, interrupt coverage, idempotency,
|
|
69
|
+
* and expiry, and none of them mentions `messages`. Hosts therefore disagree,
|
|
70
|
+
* and both readings are conformant.
|
|
71
|
+
*
|
|
72
|
+
* `"full"` sends the whole thread, which is what `@ag-ui/client` itself does.
|
|
73
|
+
* Hosts that resume from a checkpoint ignore the transcript, and hosts that
|
|
74
|
+
* rebuild the interrupted run from it need it.
|
|
75
|
+
*
|
|
76
|
+
* `"appended"` sends only what was appended locally after the interrupted
|
|
77
|
+
* assistant message, which is nothing for an approval or a denial and the new
|
|
78
|
+
* user turn for `steerAway`. Choose it for a host that owns the thread and
|
|
79
|
+
* seeds a resume request from its own stored snapshot, because such a host
|
|
80
|
+
* appends the request body to that snapshot and a re-sent transcript
|
|
81
|
+
* duplicates the interrupted turn in its stored history. A host that rebuilds
|
|
82
|
+
* the run from `messages` has nothing to resume from under `"appended"`, and
|
|
83
|
+
* a host that derives its outgoing `MESSAGES_SNAPSHOT` from the request body
|
|
84
|
+
* emits a truncated one.
|
|
85
|
+
*
|
|
86
|
+
* Defaults to `"full"`.
|
|
87
|
+
*/
|
|
88
|
+
resumeTranscript?: AgUiResumeTranscript | undefined;
|
|
63
89
|
/**
|
|
64
90
|
* When the user sends, edits, or reloads a message while client-side tool
|
|
65
91
|
* calls are still pending, automatically cancel the unresolved tool calls
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
4
|
+
import { startTransition, useEffect } from "react";
|
|
4
5
|
import {
|
|
5
6
|
act,
|
|
6
7
|
cleanup,
|
|
@@ -409,6 +410,59 @@ describe("useAgUiRuntime unstable_enableMessageQueue", () => {
|
|
|
409
410
|
);
|
|
410
411
|
});
|
|
411
412
|
|
|
413
|
+
it("does not clear the committed queue from a suspended render", async () => {
|
|
414
|
+
const { agent, runAgent } = gatedAgent();
|
|
415
|
+
let runtime: AssistantRuntime | undefined;
|
|
416
|
+
const never = new Promise<void>(() => {});
|
|
417
|
+
|
|
418
|
+
const Harness = ({
|
|
419
|
+
enabled,
|
|
420
|
+
suspend,
|
|
421
|
+
}: {
|
|
422
|
+
enabled: boolean;
|
|
423
|
+
suspend: boolean;
|
|
424
|
+
}) => {
|
|
425
|
+
const nextRuntime = useAgUiRuntime({
|
|
426
|
+
agent,
|
|
427
|
+
unstable_enableMessageQueue: enabled,
|
|
428
|
+
});
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
runtime = nextRuntime;
|
|
431
|
+
}, [nextRuntime]);
|
|
432
|
+
if (suspend) throw never;
|
|
433
|
+
return null;
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const view = render(<Harness enabled suspend={false} />);
|
|
437
|
+
await waitFor(() => expect(runtime).toBeDefined());
|
|
438
|
+
mount(runtime!);
|
|
439
|
+
|
|
440
|
+
await act(async () => {
|
|
441
|
+
await runtime!.thread.append({
|
|
442
|
+
role: "user",
|
|
443
|
+
content: [{ type: "text", text: "first" }],
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
|
|
447
|
+
|
|
448
|
+
await act(async () => {
|
|
449
|
+
await runtime!.thread.append({
|
|
450
|
+
role: "user",
|
|
451
|
+
content: [{ type: "text", text: "second" }],
|
|
452
|
+
parentId: runtime!.thread.getState().messages.at(-1)?.id ?? null,
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
await waitFor(() =>
|
|
456
|
+
expect(screen.getByTestId("queued").textContent).toBe("second"),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
act(() => {
|
|
460
|
+
startTransition(() => view.rerender(<Harness enabled={false} suspend />));
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(screen.getByTestId("queued").textContent).toBe("second");
|
|
464
|
+
});
|
|
465
|
+
|
|
412
466
|
it("leaves the queue capability off when the flag is not set", () => {
|
|
413
467
|
const { agent } = gatedAgent();
|
|
414
468
|
|