@assistant-ui/react 0.15.8 → 0.15.10
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/LICENSE +21 -0
- package/dist/client/ExternalThread.d.ts +3 -1
- package/dist/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +556 -363
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.d.ts +1 -0
- package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.js +2 -1
- package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +2 -0
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +32 -7
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/package.json +14 -16
- package/src/client/ExternalThread.ts +175 -10
- package/src/legacy-runtime/cloud/auiV0.ts +8 -1
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +1 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +79 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +2 -0
- package/src/tests/external-thread-feedback.test.tsx +204 -0
- package/src/tests/external-thread-parity.test.tsx +29 -0
- package/src/tests/external-thread-speech.test.tsx +328 -0
- package/src/unstable/useLiveCompletionAdapter.test.tsx +90 -0
- package/src/unstable/useLiveCompletionAdapter.ts +32 -7
|
@@ -33,6 +33,9 @@ import type {
|
|
|
33
33
|
ExternalThreadQueueAdapter,
|
|
34
34
|
ExternalThreadBranchAdapter,
|
|
35
35
|
QueuePlacement,
|
|
36
|
+
FeedbackAdapter,
|
|
37
|
+
SpeechState,
|
|
38
|
+
SpeechSynthesisAdapter,
|
|
36
39
|
} from "@assistant-ui/core";
|
|
37
40
|
import { ToolResponse } from "assistant-stream";
|
|
38
41
|
import type { ReadonlyJSONValue } from "assistant-stream/utils";
|
|
@@ -97,6 +100,8 @@ export type ExternalThreadProps = {
|
|
|
97
100
|
onResumeToolCall?: ((options: ResumeToolCallOptions) => void) | undefined;
|
|
98
101
|
onLoadExternalState?: ((state: unknown) => void) | undefined;
|
|
99
102
|
attachmentAdapter?: AttachmentAdapter | undefined;
|
|
103
|
+
feedbackAdapter?: FeedbackAdapter | undefined;
|
|
104
|
+
speechAdapter?: SpeechSynthesisAdapter | undefined;
|
|
100
105
|
/** Queue adapter for runtimes that support message queuing and steering. */
|
|
101
106
|
queue?: ExternalThreadQueueAdapter;
|
|
102
107
|
/** Branch adapter for runtimes that track sibling variants of messages. */
|
|
@@ -119,6 +124,11 @@ type MessageClientProps = {
|
|
|
119
124
|
onAddToolResult?: ((options: AddToolResultOptions) => void) | undefined;
|
|
120
125
|
onResumeToolCall?: ((options: ResumeToolCallOptions) => void) | undefined;
|
|
121
126
|
attachmentAdapter?: AttachmentAdapter | undefined;
|
|
127
|
+
submittedFeedback: "positive" | "negative" | undefined;
|
|
128
|
+
onSubmitFeedback: (feedback: { type: "positive" | "negative" }) => void;
|
|
129
|
+
speech: SpeechState | undefined;
|
|
130
|
+
onSpeak: () => void;
|
|
131
|
+
onStopSpeaking: () => void;
|
|
122
132
|
};
|
|
123
133
|
|
|
124
134
|
// Message Client - minimal implementation
|
|
@@ -134,6 +144,11 @@ const useMessageClient = ({
|
|
|
134
144
|
onAddToolResult,
|
|
135
145
|
onResumeToolCall,
|
|
136
146
|
attachmentAdapter,
|
|
147
|
+
submittedFeedback,
|
|
148
|
+
onSubmitFeedback,
|
|
149
|
+
speech,
|
|
150
|
+
onSpeak,
|
|
151
|
+
onStopSpeaking,
|
|
137
152
|
}: MessageClientProps): ClientOutput<"message"> => {
|
|
138
153
|
const [isCopied, setIsCopied] = useState(false);
|
|
139
154
|
const [isHovering, setIsHovering] = useState(false);
|
|
@@ -168,6 +183,7 @@ const useMessageClient = ({
|
|
|
168
183
|
);
|
|
169
184
|
|
|
170
185
|
const handleBeginEdit = () => {
|
|
186
|
+
if (!onEdit) throw new Error("Runtime does not support editing.");
|
|
171
187
|
setIsEditing(true);
|
|
172
188
|
};
|
|
173
189
|
|
|
@@ -176,7 +192,8 @@ const useMessageClient = ({
|
|
|
176
192
|
};
|
|
177
193
|
|
|
178
194
|
const handleSendEdit = (msg: AppendMessage) => {
|
|
179
|
-
onEdit
|
|
195
|
+
if (!onEdit) throw new Error("Runtime does not support editing.");
|
|
196
|
+
onEdit({
|
|
180
197
|
...msg,
|
|
181
198
|
parentId,
|
|
182
199
|
sourceId: message.id,
|
|
@@ -204,14 +221,24 @@ const useMessageClient = ({
|
|
|
204
221
|
const branchCount = branchIndex === -1 ? 1 : branchIds.length;
|
|
205
222
|
|
|
206
223
|
const state = useMemo(() => {
|
|
224
|
+
const messageWithFeedback: ExternalThreadMessage =
|
|
225
|
+
submittedFeedback && message.role === "assistant"
|
|
226
|
+
? {
|
|
227
|
+
...message,
|
|
228
|
+
metadata: {
|
|
229
|
+
...message.metadata,
|
|
230
|
+
submittedFeedback: { type: submittedFeedback },
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
: message;
|
|
207
234
|
return {
|
|
208
|
-
...
|
|
235
|
+
...messageWithFeedback,
|
|
209
236
|
attachments: message.attachments ?? [],
|
|
210
237
|
parentId,
|
|
211
238
|
isLast: false, // Will be set by thread
|
|
212
239
|
branchNumber,
|
|
213
240
|
branchCount,
|
|
214
|
-
speech
|
|
241
|
+
speech,
|
|
215
242
|
parts: partClients.state,
|
|
216
243
|
isCopied,
|
|
217
244
|
isHovering,
|
|
@@ -228,6 +255,8 @@ const useMessageClient = ({
|
|
|
228
255
|
partClients.state,
|
|
229
256
|
branchNumber,
|
|
230
257
|
branchCount,
|
|
258
|
+
submittedFeedback,
|
|
259
|
+
speech,
|
|
231
260
|
]);
|
|
232
261
|
|
|
233
262
|
return {
|
|
@@ -237,9 +266,9 @@ const useMessageClient = ({
|
|
|
237
266
|
reload: () => {
|
|
238
267
|
onReload?.();
|
|
239
268
|
},
|
|
240
|
-
speak:
|
|
241
|
-
stopSpeaking:
|
|
242
|
-
submitFeedback:
|
|
269
|
+
speak: onSpeak,
|
|
270
|
+
stopSpeaking: onStopSpeaking,
|
|
271
|
+
submitFeedback: onSubmitFeedback,
|
|
243
272
|
switchToBranch: ({ position, branchId }) => {
|
|
244
273
|
if (!branches) return;
|
|
245
274
|
const target =
|
|
@@ -721,6 +750,65 @@ const useComposerClientResource = ({
|
|
|
721
750
|
|
|
722
751
|
const ComposerClientResource = resource(useComposerClientResource);
|
|
723
752
|
|
|
753
|
+
const createSpeechController = (
|
|
754
|
+
notify: (speech: SpeechState | undefined) => void,
|
|
755
|
+
) => {
|
|
756
|
+
let session: { messageId: string; cancel: () => void } | undefined;
|
|
757
|
+
|
|
758
|
+
const clear = () => {
|
|
759
|
+
if (!session) return;
|
|
760
|
+
session.cancel();
|
|
761
|
+
session = undefined;
|
|
762
|
+
notify(undefined);
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
speak: (
|
|
767
|
+
adapter: SpeechSynthesisAdapter,
|
|
768
|
+
message: ExternalThreadMessage,
|
|
769
|
+
) => {
|
|
770
|
+
clear();
|
|
771
|
+
|
|
772
|
+
const utterance = adapter.speak(getThreadMessageText(message));
|
|
773
|
+
let unsub: (() => void) | undefined;
|
|
774
|
+
unsub = utterance.subscribe(() => {
|
|
775
|
+
if (utterance.status.type === "ended") {
|
|
776
|
+
unsub?.();
|
|
777
|
+
session = undefined;
|
|
778
|
+
notify(undefined);
|
|
779
|
+
} else {
|
|
780
|
+
notify({ messageId: message.id, status: utterance.status });
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
if (utterance.status.type === "ended") {
|
|
785
|
+
unsub();
|
|
786
|
+
notify(undefined);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
session = {
|
|
791
|
+
messageId: message.id,
|
|
792
|
+
cancel: () => {
|
|
793
|
+
unsub!();
|
|
794
|
+
utterance.cancel();
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
notify({ messageId: message.id, status: utterance.status });
|
|
798
|
+
},
|
|
799
|
+
stop: () => {
|
|
800
|
+
if (!session) throw new Error("No message is being spoken");
|
|
801
|
+
clear();
|
|
802
|
+
},
|
|
803
|
+
stopMessage: (messageId: string) => {
|
|
804
|
+
if (session?.messageId !== messageId)
|
|
805
|
+
throw new Error("Message is not being spoken");
|
|
806
|
+
clear();
|
|
807
|
+
},
|
|
808
|
+
dispose: clear,
|
|
809
|
+
};
|
|
810
|
+
};
|
|
811
|
+
|
|
724
812
|
const dedupeMessagesById = (messages: readonly ExternalThreadMessage[]) => {
|
|
725
813
|
const seenIds = new Set<string>();
|
|
726
814
|
const deduped: ExternalThreadMessage[] = [];
|
|
@@ -756,6 +844,8 @@ const useExternalThread = ({
|
|
|
756
844
|
onResumeToolCall,
|
|
757
845
|
onLoadExternalState,
|
|
758
846
|
attachmentAdapter,
|
|
847
|
+
feedbackAdapter,
|
|
848
|
+
speechAdapter,
|
|
759
849
|
queue,
|
|
760
850
|
branches,
|
|
761
851
|
onRespondToToolApproval,
|
|
@@ -765,6 +855,71 @@ const useExternalThread = ({
|
|
|
765
855
|
[messagesProp],
|
|
766
856
|
);
|
|
767
857
|
|
|
858
|
+
// Local entries are optimistic: they apply only while the message's
|
|
859
|
+
// external submittedFeedback still equals the value seen at click time.
|
|
860
|
+
const [submittedFeedback, setSubmittedFeedback] = useState<
|
|
861
|
+
Record<
|
|
862
|
+
string,
|
|
863
|
+
{
|
|
864
|
+
type: "positive" | "negative";
|
|
865
|
+
external: "positive" | "negative" | undefined;
|
|
866
|
+
}
|
|
867
|
+
>
|
|
868
|
+
>({});
|
|
869
|
+
|
|
870
|
+
const feedbackFor = (msg: ExternalThreadMessage) => {
|
|
871
|
+
const entry = submittedFeedback[msg.id];
|
|
872
|
+
return entry && msg.metadata.submittedFeedback?.type === entry.external
|
|
873
|
+
? entry.type
|
|
874
|
+
: undefined;
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
useEffect(() => {
|
|
878
|
+
setSubmittedFeedback((prev) => {
|
|
879
|
+
const live = Object.entries(prev).filter(([id, entry]) => {
|
|
880
|
+
const msg = messages.find((m) => m.id === id);
|
|
881
|
+
return !!msg && msg.metadata.submittedFeedback?.type === entry.external;
|
|
882
|
+
});
|
|
883
|
+
return live.length === Object.keys(prev).length
|
|
884
|
+
? prev
|
|
885
|
+
: Object.fromEntries(live);
|
|
886
|
+
});
|
|
887
|
+
}, [messages]);
|
|
888
|
+
|
|
889
|
+
const handleSubmitFeedback = (
|
|
890
|
+
message: ExternalThreadMessage,
|
|
891
|
+
{ type }: { type: "positive" | "negative" },
|
|
892
|
+
) => {
|
|
893
|
+
if (!feedbackAdapter) throw new Error("Feedback adapter not configured");
|
|
894
|
+
feedbackAdapter.submit({ message, type });
|
|
895
|
+
|
|
896
|
+
if (message.role === "assistant") {
|
|
897
|
+
setSubmittedFeedback((prev) => ({
|
|
898
|
+
...prev,
|
|
899
|
+
[message.id]: {
|
|
900
|
+
type,
|
|
901
|
+
external: message.metadata.submittedFeedback?.type,
|
|
902
|
+
},
|
|
903
|
+
}));
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
const [speechState, setSpeech] = useState<SpeechState | undefined>(undefined);
|
|
908
|
+
const [speechController] = useState(() => createSpeechController(setSpeech));
|
|
909
|
+
|
|
910
|
+
const hasSpeechAdapter = !!speechAdapter;
|
|
911
|
+
const speech = hasSpeechAdapter ? speechState : undefined;
|
|
912
|
+
useEffect(() => {
|
|
913
|
+
if (!hasSpeechAdapter) speechController.dispose();
|
|
914
|
+
}, [hasSpeechAdapter, speechController]);
|
|
915
|
+
|
|
916
|
+
useEffect(() => () => speechController.dispose(), [speechController]);
|
|
917
|
+
|
|
918
|
+
const handleSpeak = (message: ExternalThreadMessage) => {
|
|
919
|
+
if (!speechAdapter) throw new Error("Speech adapter not configured");
|
|
920
|
+
speechController.speak(speechAdapter, message);
|
|
921
|
+
};
|
|
922
|
+
|
|
768
923
|
const handleReload = (messageId: string) => {
|
|
769
924
|
const messageIndex = messages.findIndex((m) => m.id === messageId);
|
|
770
925
|
if (messageIndex === -1) return;
|
|
@@ -786,6 +941,11 @@ const useExternalThread = ({
|
|
|
786
941
|
onAddToolResult,
|
|
787
942
|
onResumeToolCall,
|
|
788
943
|
attachmentAdapter,
|
|
944
|
+
submittedFeedback: feedbackFor(msg),
|
|
945
|
+
onSubmitFeedback: (feedback) => handleSubmitFeedback(msg, feedback),
|
|
946
|
+
speech: speech?.messageId === msg.id ? speech : undefined,
|
|
947
|
+
onSpeak: () => handleSpeak(msg),
|
|
948
|
+
onStopSpeaking: () => speechController.stopMessage(msg.id),
|
|
789
949
|
};
|
|
790
950
|
if (onEdit) props.onEdit = onEdit;
|
|
791
951
|
return withKey(msg.id, MessageClient(props));
|
|
@@ -833,6 +993,8 @@ const useExternalThread = ({
|
|
|
833
993
|
const hasEdit = !!onEdit;
|
|
834
994
|
const hasReload = !!onReload;
|
|
835
995
|
const hasAttachments = !!attachmentAdapter;
|
|
996
|
+
const hasFeedback = !!feedbackAdapter;
|
|
997
|
+
const hasSpeech = !!speechAdapter;
|
|
836
998
|
const state = useMemo(() => {
|
|
837
999
|
const messageStates = messageClients.state.map((s, idx, arr) => ({
|
|
838
1000
|
...s,
|
|
@@ -850,9 +1012,9 @@ const useExternalThread = ({
|
|
|
850
1012
|
reload: hasReload,
|
|
851
1013
|
refetchThread: false,
|
|
852
1014
|
cancel: isRunning,
|
|
853
|
-
speech:
|
|
1015
|
+
speech: hasSpeech,
|
|
854
1016
|
attachments: hasAttachments,
|
|
855
|
-
feedback:
|
|
1017
|
+
feedback: hasFeedback,
|
|
856
1018
|
voice: false,
|
|
857
1019
|
switchToBranch: hasBranches,
|
|
858
1020
|
switchBranchDuringRun: false,
|
|
@@ -864,7 +1026,7 @@ const useExternalThread = ({
|
|
|
864
1026
|
state: threadState ?? {},
|
|
865
1027
|
suggestions: [],
|
|
866
1028
|
extras,
|
|
867
|
-
speech
|
|
1029
|
+
speech,
|
|
868
1030
|
voice: undefined,
|
|
869
1031
|
composer: composerClient.state,
|
|
870
1032
|
};
|
|
@@ -879,6 +1041,9 @@ const useExternalThread = ({
|
|
|
879
1041
|
hasEdit,
|
|
880
1042
|
hasReload,
|
|
881
1043
|
hasAttachments,
|
|
1044
|
+
hasFeedback,
|
|
1045
|
+
hasSpeech,
|
|
1046
|
+
speech,
|
|
882
1047
|
messageClients.state,
|
|
883
1048
|
composerClient.state,
|
|
884
1049
|
]);
|
|
@@ -945,7 +1110,7 @@ const useExternalThread = ({
|
|
|
945
1110
|
}
|
|
946
1111
|
return messageClients.get(selector);
|
|
947
1112
|
},
|
|
948
|
-
stopSpeaking:
|
|
1113
|
+
stopSpeaking: speechController.stop,
|
|
949
1114
|
connectVoice: () => {},
|
|
950
1115
|
disconnectVoice: () => {},
|
|
951
1116
|
getVoiceVolume: () => 0,
|
|
@@ -32,6 +32,7 @@ type AuiV0MessagePart =
|
|
|
32
32
|
| {
|
|
33
33
|
readonly type: "reasoning";
|
|
34
34
|
readonly text: string;
|
|
35
|
+
readonly unstable_summary?: string;
|
|
35
36
|
}
|
|
36
37
|
| {
|
|
37
38
|
readonly type: "source";
|
|
@@ -226,7 +227,13 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
|
|
|
226
227
|
return { type: "text", text: part.text };
|
|
227
228
|
|
|
228
229
|
case "reasoning":
|
|
229
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
type: "reasoning",
|
|
232
|
+
text: part.text,
|
|
233
|
+
...(part.unstable_summary !== undefined
|
|
234
|
+
? { unstable_summary: part.unstable_summary }
|
|
235
|
+
: undefined),
|
|
236
|
+
};
|
|
230
237
|
|
|
231
238
|
case "source":
|
|
232
239
|
if (part.sourceType === "url") {
|
|
@@ -23,6 +23,7 @@ Resume State
|
|
|
23
23
|
- The resume request carries `runId` and no `state`: the server replays from the snapshot it retained for that run, so neither `body` overrides nor a `prepareSendCommandsRequest` rebuild can substitute a different base. `runId` takes precedence over `body` fields and is re-attached after `prepareSendCommandsRequest` so a rebuilt body cannot drop it. The local base is replaced by the snapshot only after the resume stream responds OK, so a rejected resume keeps the local state.
|
|
24
24
|
- A 204 response means no active run: the resume is skipped without error, and queued commands are flushed in a follow-up run.
|
|
25
25
|
- A malformed snapshot response fails the resume before the replay request is sent.
|
|
26
|
+
- If a pending resume is dropped by an error or cancellation, later commands start normal runs.
|
|
26
27
|
- Runs execute on a `queueMicrotask`, so multiple synchronous enqueues coalesce into a single request: the first run's flush takes all of them, and the coalesced follow-up run no-ops.
|
|
27
28
|
|
|
28
29
|
Command Queue
|
package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx
CHANGED
|
@@ -75,6 +75,35 @@ const installFetch = () => {
|
|
|
75
75
|
return { requests, servers };
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
+
const installPendingFetch = () => {
|
|
79
|
+
const requests: RecordedRequest[] = [];
|
|
80
|
+
const pending: {
|
|
81
|
+
resolve: (response: Response) => void;
|
|
82
|
+
reject: (reason: unknown) => void;
|
|
83
|
+
}[] = [];
|
|
84
|
+
|
|
85
|
+
vi.stubGlobal("fetch", (url: RequestInfo | URL, init: RequestInit = {}) => {
|
|
86
|
+
requests.push({
|
|
87
|
+
url: String(url),
|
|
88
|
+
init,
|
|
89
|
+
body: JSON.parse(init.body as string),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return new Promise<Response>((resolve, reject) => {
|
|
93
|
+
pending.push({ resolve, reject });
|
|
94
|
+
init.signal?.addEventListener(
|
|
95
|
+
"abort",
|
|
96
|
+
() => reject(init.signal?.reason),
|
|
97
|
+
{
|
|
98
|
+
once: true,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return { requests, pending };
|
|
105
|
+
};
|
|
106
|
+
|
|
78
107
|
const mountRuntime = (
|
|
79
108
|
options?: Partial<AssistantTransportOptions<unknown>>,
|
|
80
109
|
) => {
|
|
@@ -216,6 +245,56 @@ describe("useAssistantTransportRuntime", () => {
|
|
|
216
245
|
await waitFor(() => expect(aui().thread.getState().isRunning).toBe(false));
|
|
217
246
|
});
|
|
218
247
|
|
|
248
|
+
it.each(["error", "cancellation"] as const)(
|
|
249
|
+
"does not apply a dropped resume after run %s",
|
|
250
|
+
async (settlement) => {
|
|
251
|
+
const fetchMock = installPendingFetch();
|
|
252
|
+
const onError = vi.fn();
|
|
253
|
+
const { aui, sendCommand } = mountRuntime({
|
|
254
|
+
resumeApi: "https://example.com/resume",
|
|
255
|
+
onError,
|
|
256
|
+
});
|
|
257
|
+
await waitFor(() =>
|
|
258
|
+
expect(
|
|
259
|
+
(aui().thread.getState().extras as { sendCommand?: unknown })
|
|
260
|
+
?.sendCommand,
|
|
261
|
+
).toBeTypeOf("function"),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
act(() => sendCommand(createMessageCommand("a")));
|
|
265
|
+
await waitFor(() => expect(fetchMock.requests).toHaveLength(1));
|
|
266
|
+
|
|
267
|
+
await act(async () => {
|
|
268
|
+
await aui().thread.resumeRun({ parentId: null });
|
|
269
|
+
});
|
|
270
|
+
if (settlement === "cancellation") {
|
|
271
|
+
act(() => aui().thread.cancelRun());
|
|
272
|
+
} else {
|
|
273
|
+
await act(async () => {
|
|
274
|
+
fetchMock.pending[0]!.reject(new Error("request failed"));
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
await waitFor(() =>
|
|
278
|
+
expect(aui().thread.getState().isRunning).toBe(false),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
act(() => sendCommand(createMessageCommand("b")));
|
|
282
|
+
await waitFor(() => expect(fetchMock.requests).toHaveLength(2));
|
|
283
|
+
expect(fetchMock.requests[1]!.url).toBe("https://example.com/api");
|
|
284
|
+
expect(fetchMock.requests[1]!.body["commands"]).toEqual([
|
|
285
|
+
createMessageCommand("b"),
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
await act(async () => {
|
|
289
|
+
fetchMock.pending[1]!.resolve(new Response("", { status: 200 }));
|
|
290
|
+
});
|
|
291
|
+
await waitFor(() =>
|
|
292
|
+
expect(aui().thread.getState().isRunning).toBe(false),
|
|
293
|
+
);
|
|
294
|
+
expect(onError).toHaveBeenCalledTimes(settlement === "error" ? 1 : 0);
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
|
|
219
298
|
it("applies resumed operations to the retained initial state", async () => {
|
|
220
299
|
const requests: RecordedRequest[] = [];
|
|
221
300
|
vi.stubGlobal(
|
package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts
CHANGED
|
@@ -348,6 +348,7 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
348
348
|
});
|
|
349
349
|
},
|
|
350
350
|
onError: async (error) => {
|
|
351
|
+
resumeFlagRef.current = false;
|
|
351
352
|
setIsReplaying(false);
|
|
352
353
|
const inTransitCmds = [...commandQueue.state.inTransit];
|
|
353
354
|
const queuedCmds = [...commandQueue.state.queued];
|
|
@@ -422,6 +423,7 @@ const useAssistantTransportThreadRuntime = <T>(
|
|
|
422
423
|
},
|
|
423
424
|
}),
|
|
424
425
|
onCancel: async () => {
|
|
426
|
+
resumeFlagRef.current = false;
|
|
425
427
|
runManager.cancel();
|
|
426
428
|
},
|
|
427
429
|
onResume: async () => {
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
4
|
+
import type { FC } from "react";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { AuiProvider, useAui } from "@assistant-ui/store";
|
|
7
|
+
import type { FeedbackAdapter } from "@assistant-ui/core";
|
|
8
|
+
import type {
|
|
9
|
+
ExternalThreadMessage,
|
|
10
|
+
ExternalThreadProps,
|
|
11
|
+
} from "../client/ExternalThread";
|
|
12
|
+
import { ExternalThread } from "../client/ExternalThread";
|
|
13
|
+
|
|
14
|
+
const MESSAGES = [
|
|
15
|
+
{
|
|
16
|
+
id: "u1",
|
|
17
|
+
role: "user",
|
|
18
|
+
content: [{ type: "text", text: "hi" }],
|
|
19
|
+
createdAt: new Date(0),
|
|
20
|
+
attachments: [],
|
|
21
|
+
metadata: { custom: {} },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "a1",
|
|
25
|
+
role: "assistant",
|
|
26
|
+
content: [{ type: "text", text: "hello there" }],
|
|
27
|
+
createdAt: new Date(0),
|
|
28
|
+
metadata: { custom: {} },
|
|
29
|
+
},
|
|
30
|
+
] as unknown as readonly ExternalThreadMessage[];
|
|
31
|
+
|
|
32
|
+
const createFakeAdapter = () => {
|
|
33
|
+
const submit = vi.fn();
|
|
34
|
+
const adapter: FeedbackAdapter = { submit };
|
|
35
|
+
return { adapter, submit };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const renderThreadWithProps = (props: Partial<ExternalThreadProps>) => {
|
|
39
|
+
const captured: { aui?: ReturnType<typeof useAui> } = {};
|
|
40
|
+
const Capture: FC = () => {
|
|
41
|
+
captured.aui = useAui();
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
const App: FC<{ props: Partial<ExternalThreadProps> }> = ({ props }) => {
|
|
45
|
+
const aui = useAui({
|
|
46
|
+
thread: ExternalThread({
|
|
47
|
+
messages: MESSAGES,
|
|
48
|
+
isRunning: false,
|
|
49
|
+
...props,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
return (
|
|
53
|
+
<AuiProvider value={aui}>
|
|
54
|
+
<Capture />
|
|
55
|
+
</AuiProvider>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const view = render(<App props={props} />);
|
|
60
|
+
return {
|
|
61
|
+
aui: () => captured.aui!,
|
|
62
|
+
rerender: (nextProps: Partial<ExternalThreadProps>) =>
|
|
63
|
+
view.rerender(<App props={nextProps} />),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe("ExternalThread feedback", () => {
|
|
68
|
+
it("reports the feedback capability based on adapter presence", () => {
|
|
69
|
+
const { aui: withoutAdapter } = renderThreadWithProps({});
|
|
70
|
+
expect(withoutAdapter().thread.getState().capabilities.feedback).toBe(
|
|
71
|
+
false,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const { adapter } = createFakeAdapter();
|
|
75
|
+
const { aui: withAdapter } = renderThreadWithProps({
|
|
76
|
+
feedbackAdapter: adapter,
|
|
77
|
+
});
|
|
78
|
+
expect(withAdapter().thread.getState().capabilities.feedback).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("throws on submitFeedback when no adapter is configured", () => {
|
|
82
|
+
const { aui } = renderThreadWithProps({});
|
|
83
|
+
expect(() =>
|
|
84
|
+
aui().thread.message({ id: "a1" }).submitFeedback({ type: "positive" }),
|
|
85
|
+
).toThrow("Feedback adapter not configured");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("submits feedback to the adapter and marks the assistant message", async () => {
|
|
89
|
+
const { adapter, submit } = createFakeAdapter();
|
|
90
|
+
const { aui } = renderThreadWithProps({ feedbackAdapter: adapter });
|
|
91
|
+
|
|
92
|
+
await act(async () => {
|
|
93
|
+
aui().thread.message({ id: "a1" }).submitFeedback({ type: "positive" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(submit).toHaveBeenCalledWith({
|
|
98
|
+
message: MESSAGES[1],
|
|
99
|
+
type: "positive",
|
|
100
|
+
});
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
expect(
|
|
103
|
+
aui().thread.message({ id: "a1" }).getState().metadata
|
|
104
|
+
.submittedFeedback,
|
|
105
|
+
).toEqual({ type: "positive" });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await act(async () => {
|
|
109
|
+
aui().thread.message({ id: "a1" }).submitFeedback({ type: "negative" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(submit).toHaveBeenLastCalledWith({
|
|
113
|
+
message: MESSAGES[1],
|
|
114
|
+
type: "negative",
|
|
115
|
+
});
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
expect(
|
|
118
|
+
aui().thread.message({ id: "a1" }).getState().metadata
|
|
119
|
+
.submittedFeedback,
|
|
120
|
+
).toEqual({ type: "negative" });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("prefers owner-supplied submittedFeedback over the local overlay", async () => {
|
|
125
|
+
const { adapter } = createFakeAdapter();
|
|
126
|
+
const { aui, rerender } = renderThreadWithProps({
|
|
127
|
+
feedbackAdapter: adapter,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await act(async () => {
|
|
131
|
+
aui().thread.message({ id: "a1" }).submitFeedback({ type: "positive" });
|
|
132
|
+
});
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(
|
|
135
|
+
aui().thread.message({ id: "a1" }).getState().metadata
|
|
136
|
+
.submittedFeedback,
|
|
137
|
+
).toEqual({ type: "positive" });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const ownerMessages = [
|
|
141
|
+
MESSAGES[0]!,
|
|
142
|
+
{
|
|
143
|
+
...MESSAGES[1]!,
|
|
144
|
+
metadata: { custom: {}, submittedFeedback: { type: "negative" } },
|
|
145
|
+
},
|
|
146
|
+
] as unknown as readonly ExternalThreadMessage[];
|
|
147
|
+
await act(async () => {
|
|
148
|
+
rerender({ feedbackAdapter: adapter, messages: ownerMessages });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(
|
|
152
|
+
aui().thread.message({ id: "a1" }).getState().metadata.submittedFeedback,
|
|
153
|
+
).toEqual({ type: "negative" });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("re-rates an owner-marked message locally, then honors an owner clear", async () => {
|
|
157
|
+
const { adapter } = createFakeAdapter();
|
|
158
|
+
const ratedMessages = [
|
|
159
|
+
MESSAGES[0]!,
|
|
160
|
+
{
|
|
161
|
+
...MESSAGES[1]!,
|
|
162
|
+
metadata: { custom: {}, submittedFeedback: { type: "positive" } },
|
|
163
|
+
},
|
|
164
|
+
] as unknown as readonly ExternalThreadMessage[];
|
|
165
|
+
const { aui, rerender } = renderThreadWithProps({
|
|
166
|
+
feedbackAdapter: adapter,
|
|
167
|
+
messages: ratedMessages,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await act(async () => {
|
|
171
|
+
aui().thread.message({ id: "a1" }).submitFeedback({ type: "negative" });
|
|
172
|
+
});
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(
|
|
175
|
+
aui().thread.message({ id: "a1" }).getState().metadata
|
|
176
|
+
.submittedFeedback,
|
|
177
|
+
).toEqual({ type: "negative" });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await act(async () => {
|
|
181
|
+
rerender({ feedbackAdapter: adapter, messages: MESSAGES });
|
|
182
|
+
});
|
|
183
|
+
expect(
|
|
184
|
+
aui().thread.message({ id: "a1" }).getState().metadata.submittedFeedback,
|
|
185
|
+
).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("submits user message feedback without marking the message", async () => {
|
|
189
|
+
const { adapter, submit } = createFakeAdapter();
|
|
190
|
+
const { aui } = renderThreadWithProps({ feedbackAdapter: adapter });
|
|
191
|
+
|
|
192
|
+
await act(async () => {
|
|
193
|
+
aui().thread.message({ id: "u1" }).submitFeedback({ type: "negative" });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(submit).toHaveBeenCalledWith({
|
|
197
|
+
message: MESSAGES[0],
|
|
198
|
+
type: "negative",
|
|
199
|
+
});
|
|
200
|
+
expect(
|
|
201
|
+
aui().thread.message({ id: "u1" }).getState().metadata.submittedFeedback,
|
|
202
|
+
).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -322,6 +322,35 @@ describe("ExternalThread composer", () => {
|
|
|
322
322
|
expect(steer).not.toHaveBeenCalled();
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
+
it("throws on beginEdit when the runtime has no edit handler", () => {
|
|
326
|
+
const { aui } = renderThread({
|
|
327
|
+
messages: [
|
|
328
|
+
{
|
|
329
|
+
id: "u1",
|
|
330
|
+
role: "user",
|
|
331
|
+
content: [{ type: "text", text: "hi" }],
|
|
332
|
+
createdAt: new Date(0),
|
|
333
|
+
attachments: [],
|
|
334
|
+
metadata: { custom: {} },
|
|
335
|
+
} as unknown as ExternalThreadMessage,
|
|
336
|
+
],
|
|
337
|
+
isRunning: false,
|
|
338
|
+
queue: {
|
|
339
|
+
items: [],
|
|
340
|
+
steerItems: [],
|
|
341
|
+
enqueue: vi.fn(),
|
|
342
|
+
steer: vi.fn(),
|
|
343
|
+
move: vi.fn(),
|
|
344
|
+
edit: vi.fn(),
|
|
345
|
+
remove: vi.fn(),
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(() =>
|
|
350
|
+
aui().thread.message({ id: "u1" }).composer().beginEdit(),
|
|
351
|
+
).toThrow("Runtime does not support editing.");
|
|
352
|
+
});
|
|
353
|
+
|
|
325
354
|
it("still refuses to send an empty composer synchronously after a send", async () => {
|
|
326
355
|
const onNew = vi.fn();
|
|
327
356
|
const { aui } = renderThread({ messages: [], isRunning: false, onNew });
|