@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.
Files changed (26) hide show
  1. package/LICENSE +21 -0
  2. package/dist/client/ExternalThread.d.ts +3 -1
  3. package/dist/client/ExternalThread.d.ts.map +1 -1
  4. package/dist/client/ExternalThread.js +556 -363
  5. package/dist/client/ExternalThread.js.map +1 -1
  6. package/dist/legacy-runtime/cloud/auiV0.d.ts +1 -0
  7. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  8. package/dist/legacy-runtime/cloud/auiV0.js +2 -1
  9. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  10. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  11. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +2 -0
  12. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  13. package/dist/unstable/useLiveCompletionAdapter.js +32 -7
  14. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  15. package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
  16. package/package.json +14 -16
  17. package/src/client/ExternalThread.ts +175 -10
  18. package/src/legacy-runtime/cloud/auiV0.ts +8 -1
  19. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +1 -0
  20. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +79 -0
  21. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +2 -0
  22. package/src/tests/external-thread-feedback.test.tsx +204 -0
  23. package/src/tests/external-thread-parity.test.tsx +29 -0
  24. package/src/tests/external-thread-speech.test.tsx +328 -0
  25. package/src/unstable/useLiveCompletionAdapter.test.tsx +90 -0
  26. 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
- ...message,
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: undefined,
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: false,
1015
+ speech: hasSpeech,
854
1016
  attachments: hasAttachments,
855
- feedback: false,
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: undefined,
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 { type: "reasoning", text: part.text };
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
@@ -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(
@@ -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 });