@assistant-ui/react-ag-ui 0.0.60 → 0.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/agUiExtras.d.ts +1 -3
  2. package/dist/agUiExtras.d.ts.map +1 -1
  3. package/dist/hooks.d.ts +3 -5
  4. package/dist/hooks.d.ts.map +1 -1
  5. package/dist/index.d.ts +7 -5
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +152 -144
  8. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  9. package/dist/runtime/AgUiThreadRuntimeCore.js +141 -23
  10. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  11. package/dist/runtime/adapter/conversions.d.ts +51 -53
  12. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  13. package/dist/runtime/adapter/conversions.js +65 -21
  14. package/dist/runtime/adapter/conversions.js.map +1 -1
  15. package/dist/runtime/adapter/run-aggregator.d.ts +89 -88
  16. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  17. package/dist/runtime/adapter/run-aggregator.js +35 -2
  18. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  19. package/dist/runtime/adapter/subscriber.d.ts +107 -108
  20. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  21. package/dist/runtime/adapter/tool-approval.d.ts +3 -4
  22. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  23. package/dist/runtime/event-parser.d.ts +3 -5
  24. package/dist/runtime/event-parser.d.ts.map +1 -1
  25. package/dist/runtime/interrupt-internals.d.ts +1 -3
  26. package/dist/runtime/interrupt-internals.d.ts.map +1 -1
  27. package/dist/runtime/logger.d.ts +2 -4
  28. package/dist/runtime/logger.d.ts.map +1 -1
  29. package/dist/runtime/mcp-tool-result.d.ts +4 -6
  30. package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
  31. package/dist/runtime/types.d.ts +237 -238
  32. package/dist/runtime/types.d.ts.map +1 -1
  33. package/dist/useAgUiRuntime.d.ts +13 -15
  34. package/dist/useAgUiRuntime.d.ts.map +1 -1
  35. package/dist/useAgUiRuntime.js +9 -2
  36. package/dist/useAgUiRuntime.js.map +1 -1
  37. package/package.json +6 -6
  38. package/src/runtime/AgUiThreadRuntimeCore.ts +239 -32
  39. package/src/runtime/adapter/conversions.content.test.ts +56 -0
  40. package/src/runtime/adapter/conversions.ts +112 -44
  41. package/src/runtime/adapter/run-aggregator.ts +53 -2
  42. package/src/useAgUiRuntime.history.test.tsx +93 -2
  43. package/src/useAgUiRuntime.thread-switch.test.tsx +84 -4
  44. package/src/useAgUiRuntime.ts +12 -3
  45. package/src/useAgUiRuntime.voice.test.tsx +402 -0
@@ -190,10 +190,14 @@ export function useAgUiRuntime(
190
190
  onSwitchToNewThread: onSwitchToNewThread
191
191
  ? async () => {
192
192
  const generation = ++threadSwitchGenerationRef.current;
193
+ // Clear before the thread id flips, or the old messages leak
194
+ // into the new thread as a sibling branch.
195
+ core.applyExternalMessages([]);
196
+ core.resetThreadState();
193
197
  await onSwitchToNewThread();
194
198
  if (generation !== threadSwitchGenerationRef.current) return;
195
199
  core.applyExternalMessages([]);
196
- core.resetState();
200
+ core.resetThreadState();
197
201
  }
198
202
  : undefined,
199
203
  onSwitchToThread: onSwitchToThread
@@ -202,13 +206,14 @@ export function useAgUiRuntime(
202
206
  // Clear before the thread id flips, or the old messages leak
203
207
  // into the new thread as a sibling branch.
204
208
  core.applyExternalMessages([]);
209
+ core.resetThreadState();
205
210
  const result = await onSwitchToThread(threadId);
206
211
  if (generation !== threadSwitchGenerationRef.current) return;
212
+ core.applyExternalMessages([]);
213
+ core.resetThreadState();
207
214
  core.applyExternalMessages(result.messages);
208
215
  if (result.state !== undefined) {
209
216
  core.loadExternalState(result.state);
210
- } else {
211
- core.resetState();
212
217
  }
213
218
  if (result.unstable_resume) {
214
219
  void core.resumeInFlightRun(result.messages);
@@ -253,8 +258,10 @@ export function useAgUiRuntime(
253
258
  setState: (next) => core.setState(next),
254
259
  }),
255
260
  unstable_enableToolInvocations: true,
261
+ unstable_persistsHistory: true,
256
262
  setToolStatuses,
257
263
  onNew: (message: AppendMessage) => core.append(message),
264
+ onVoiceTranscript: (message) => core.appendVoiceTranscript(message),
258
265
  onEdit: (message: AppendMessage) => {
259
266
  queueController?.clear();
260
267
  return core.edit(message);
@@ -268,6 +275,8 @@ export function useAgUiRuntime(
268
275
  core.cancel();
269
276
  },
270
277
  onAddToolResult: (options) => core.addToolResult(options),
278
+ unstable_onRecordToolInteraction: (options) =>
279
+ core.recordToolInteraction(options),
271
280
  onRespondToToolApproval: (options) =>
272
281
  core.respondToToolApproval(options).catch((error: unknown) => {
273
282
  core.reportError(error);
@@ -0,0 +1,402 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
5
+ import type { HttpAgent, RunAgentInput } from "@ag-ui/client";
6
+ import type {
7
+ RealtimeVoiceAdapter,
8
+ ThreadAssistantMessage,
9
+ ThreadHistoryAdapter,
10
+ ThreadMessage,
11
+ } from "@assistant-ui/core";
12
+ import { useAgUiRuntime } from "./useAgUiRuntime";
13
+
14
+ type Subscriber = {
15
+ onMessagesSnapshotEvent?: (payload: { event: unknown }) => void;
16
+ onRunFinalized?: () => void;
17
+ };
18
+
19
+ const priorMessage: ThreadMessage = {
20
+ id: "prior",
21
+ role: "user",
22
+ content: [{ type: "text", text: "Before voice" }],
23
+ attachments: [],
24
+ createdAt: new Date(0),
25
+ metadata: { custom: {} },
26
+ };
27
+
28
+ const createHistory = () => ({
29
+ load: vi.fn<ThreadHistoryAdapter["load"]>().mockResolvedValue({
30
+ headId: priorMessage.id,
31
+ messages: [{ parentId: null, message: priorMessage }],
32
+ }),
33
+ append: vi.fn<ThreadHistoryAdapter["append"]>().mockResolvedValue(undefined),
34
+ });
35
+
36
+ const createVoiceAdapter = (
37
+ sendText?: RealtimeVoiceAdapter.Session["sendText"],
38
+ ) => {
39
+ let transcriptCallback:
40
+ | ((transcript: RealtimeVoiceAdapter.TranscriptItem) => void)
41
+ | undefined;
42
+ const session: RealtimeVoiceAdapter.Session = {
43
+ status: { type: "running" },
44
+ isMuted: false,
45
+ disconnect: vi.fn(),
46
+ mute: vi.fn(),
47
+ unmute: vi.fn(),
48
+ ...(sendText && { sendText }),
49
+ onStatusChange: () => () => {},
50
+ onTranscript: (callback) => {
51
+ transcriptCallback = callback;
52
+ return () => {
53
+ transcriptCallback = undefined;
54
+ };
55
+ },
56
+ onModeChange: () => () => {},
57
+ onVolumeChange: () => () => {},
58
+ };
59
+ return {
60
+ adapter: { connect: () => session } satisfies RealtimeVoiceAdapter,
61
+ emitTranscript: (transcript: RealtimeVoiceAdapter.TranscriptItem) =>
62
+ transcriptCallback?.(transcript),
63
+ };
64
+ };
65
+
66
+ const renderVoiceRuntime = async ({
67
+ history = createHistory(),
68
+ sendText,
69
+ onRun,
70
+ }: {
71
+ history?: ReturnType<typeof createHistory>;
72
+ sendText?: RealtimeVoiceAdapter.Session["sendText"];
73
+ onRun?: (input: RunAgentInput, subscriber: Subscriber) => void;
74
+ } = {}) => {
75
+ const voice = createVoiceAdapter(sendText);
76
+ const runAgent = vi.fn(
77
+ async (input: RunAgentInput, subscriber: Subscriber) => {
78
+ onRun?.(input, subscriber);
79
+ subscriber.onRunFinalized?.();
80
+ },
81
+ );
82
+ const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
83
+ const rendered = renderHook(() =>
84
+ useAgUiRuntime({ agent, adapters: { voice: voice.adapter, history } }),
85
+ );
86
+ await waitFor(() => {
87
+ expect(rendered.result.current.thread.getState().isLoading).toBe(false);
88
+ });
89
+ return { ...rendered, voice, history, runAgent };
90
+ };
91
+
92
+ const emitTranscripts = (
93
+ runtime: ReturnType<typeof useAgUiRuntime>,
94
+ voice: ReturnType<typeof createVoiceAdapter>,
95
+ ) => {
96
+ let user!: ThreadMessage;
97
+ let assistant!: ThreadMessage;
98
+ act(() => {
99
+ runtime.thread.connectVoice();
100
+ voice.emitTranscript({
101
+ role: "user",
102
+ text: "Spoken question",
103
+ isFinal: true,
104
+ });
105
+ user = runtime.thread.getState().messages.at(-1)!;
106
+ voice.emitTranscript({
107
+ role: "assistant",
108
+ text: "Spoken answer",
109
+ isFinal: true,
110
+ });
111
+ assistant = runtime.thread.getState().messages.at(-1)!;
112
+ });
113
+ return { user, assistant };
114
+ };
115
+
116
+ afterEach(() => cleanup());
117
+
118
+ describe("useAgUiRuntime voice transcripts", () => {
119
+ it("keeps finalized transcripts under their runtime ids after disconnecting", async () => {
120
+ const { result, voice, history, runAgent } = await renderVoiceRuntime();
121
+ const { user, assistant } = emitTranscripts(result.current, voice);
122
+
123
+ expect(user.metadata.modality).toBe("voice");
124
+ expect(assistant.metadata.modality).toBe("voice");
125
+ expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
126
+ priorMessage.id,
127
+ user.id,
128
+ assistant.id,
129
+ ]);
130
+ expect(history.append).toHaveBeenCalledTimes(2);
131
+ expect(history.append).toHaveBeenNthCalledWith(1, {
132
+ parentId: priorMessage.id,
133
+ message: user,
134
+ });
135
+ expect(history.append).toHaveBeenNthCalledWith(2, {
136
+ parentId: user.id,
137
+ message: assistant,
138
+ });
139
+ expect(history.append.mock.calls[0]?.[0].message).toBe(user);
140
+ expect(history.append.mock.calls[1]?.[0].message).toBe(assistant);
141
+
142
+ act(() => result.current.thread.disconnectVoice());
143
+
144
+ const messages = result.current.thread.getState().messages;
145
+ expect(messages).toEqual([priorMessage, user, assistant]);
146
+ expect(messages[1]).toBe(user);
147
+ expect(messages[2]).toBe(assistant);
148
+ expect(result.current.thread.getState().voice).toBeUndefined();
149
+ expect(history.append).toHaveBeenCalledTimes(2);
150
+ expect(runAgent).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it("sends both transcripts as ordinary text before the next typed turn", async () => {
154
+ const { result, voice, runAgent } = await renderVoiceRuntime();
155
+ const { user, assistant } = emitTranscripts(result.current, voice);
156
+ expect(runAgent).not.toHaveBeenCalled();
157
+ act(() => result.current.thread.disconnectVoice());
158
+
159
+ await act(async () => {
160
+ await result.current.thread.append({
161
+ role: "user",
162
+ content: [{ type: "text", text: "Next question" }],
163
+ });
164
+ });
165
+
166
+ expect(runAgent).toHaveBeenCalledTimes(1);
167
+ expect(runAgent.mock.calls[0]?.[0].messages).toEqual([
168
+ { id: priorMessage.id, role: "user", content: "Before voice" },
169
+ { id: user.id, role: "user", content: "Spoken question" },
170
+ { id: assistant.id, role: "assistant", content: "Spoken answer" },
171
+ { id: expect.any(String), role: "user", content: "Next question" },
172
+ ]);
173
+ });
174
+
175
+ it("persists text sent into the voice session without a voice modality", async () => {
176
+ const sendText = vi.fn(async (_text: string) => {});
177
+ const { result, history, runAgent } = await renderVoiceRuntime({
178
+ sendText,
179
+ });
180
+ act(() => result.current.thread.connectVoice());
181
+
182
+ await act(async () => {
183
+ await result.current.thread.append({
184
+ role: "user",
185
+ content: [{ type: "text", text: "Typed during voice" }],
186
+ });
187
+ });
188
+
189
+ const typed = result.current.thread.getState().messages.at(-1)!;
190
+ expect(sendText).toHaveBeenCalledExactlyOnceWith("Typed during voice");
191
+ expect(typed.role).toBe("user");
192
+ expect(typed.content).toEqual([
193
+ { type: "text", text: "Typed during voice" },
194
+ ]);
195
+ expect(typed.metadata.modality).toBeUndefined();
196
+ expect(history.append).toHaveBeenCalledExactlyOnceWith({
197
+ parentId: priorMessage.id,
198
+ message: typed,
199
+ });
200
+ expect(history.append.mock.calls[0]?.[0].message).toBe(typed);
201
+ expect(runAgent).not.toHaveBeenCalled();
202
+
203
+ act(() => result.current.thread.disconnectVoice());
204
+ expect(result.current.thread.getState().messages).toEqual([
205
+ priorMessage,
206
+ typed,
207
+ ]);
208
+
209
+ await act(async () => {
210
+ await result.current.thread.append({
211
+ role: "user",
212
+ content: [{ type: "text", text: "After voice" }],
213
+ });
214
+ });
215
+ expect(runAgent).toHaveBeenCalledTimes(1);
216
+ expect(runAgent.mock.calls[0]?.[0].messages).toEqual([
217
+ { id: priorMessage.id, role: "user", content: "Before voice" },
218
+ { id: typed.id, role: "user", content: "Typed during voice" },
219
+ { id: expect.any(String), role: "user", content: "After voice" },
220
+ ]);
221
+ });
222
+
223
+ it.each([false, true])(
224
+ "preserves voice modality through snapshot echoes with reparenting %s",
225
+ async (reparent) => {
226
+ const { result, voice, history } = await renderVoiceRuntime({
227
+ onRun: (input, subscriber) => {
228
+ const messages = (
229
+ reparent ? input.messages.slice(1) : input.messages
230
+ ).map((message) =>
231
+ message.content === "Spoken question" ||
232
+ message.content === "Spoken answer"
233
+ ? { ...message, content: `Echoed ${message.role}` }
234
+ : message,
235
+ );
236
+ subscriber.onMessagesSnapshotEvent?.({
237
+ event: { type: "MESSAGES_SNAPSHOT", messages },
238
+ });
239
+ subscriber.onMessagesSnapshotEvent?.({
240
+ event: { type: "MESSAGES_SNAPSHOT", messages },
241
+ });
242
+ },
243
+ });
244
+ const { user, assistant } = emitTranscripts(result.current, voice);
245
+ act(() => result.current.thread.disconnectVoice());
246
+
247
+ await act(async () => {
248
+ await result.current.thread.append({
249
+ role: "user",
250
+ content: [{ type: "text", text: "Echo the conversation" }],
251
+ });
252
+ });
253
+
254
+ const messages = result.current.thread.getState().messages;
255
+ for (const transcript of [user, assistant]) {
256
+ const copies = messages.filter(
257
+ (message) => message.id === transcript.id,
258
+ );
259
+ expect(copies).toHaveLength(1);
260
+ expect(copies[0]).toMatchObject({
261
+ id: transcript.id,
262
+ role: transcript.role,
263
+ content: [{ type: "text", text: `Echoed ${transcript.role}` }],
264
+ metadata: { modality: "voice" },
265
+ });
266
+ expect(
267
+ history.append.mock.calls.filter(
268
+ ([item]) => item.message.id === transcript.id,
269
+ ),
270
+ ).toHaveLength(1);
271
+ }
272
+ expect(
273
+ messages.find((message) => message.id === priorMessage.id)?.metadata
274
+ .modality,
275
+ ).toBeUndefined();
276
+ },
277
+ );
278
+
279
+ it("appends a transcript after history finishes loading", async () => {
280
+ const history = createHistory();
281
+ let finishLoad!: (
282
+ repository: Awaited<ReturnType<ThreadHistoryAdapter["load"]>>,
283
+ ) => void;
284
+ history.load.mockReturnValueOnce(
285
+ new Promise((resolve) => {
286
+ finishLoad = resolve;
287
+ }),
288
+ );
289
+ const voice = createVoiceAdapter();
290
+ const runAgent = vi.fn();
291
+ const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
292
+ const { result } = renderHook(() =>
293
+ useAgUiRuntime({
294
+ agent,
295
+ adapters: { voice: voice.adapter, history },
296
+ }),
297
+ );
298
+ expect(result.current.thread.getState().isLoading).toBe(true);
299
+ act(() => {
300
+ result.current.thread.connectVoice();
301
+ voice.emitTranscript({
302
+ role: "user",
303
+ text: "During loading",
304
+ isFinal: true,
305
+ });
306
+ });
307
+ const transcript = result.current.thread.getState().messages.at(-1)!;
308
+ expect(history.append).not.toHaveBeenCalled();
309
+
310
+ await act(async () => {
311
+ finishLoad({
312
+ headId: priorMessage.id,
313
+ messages: [{ parentId: null, message: priorMessage }],
314
+ });
315
+ });
316
+ expect(history.append).toHaveBeenCalledExactlyOnceWith({
317
+ parentId: priorMessage.id,
318
+ message: transcript,
319
+ });
320
+ act(() => result.current.thread.disconnectVoice());
321
+ expect(result.current.thread.getState().messages).toEqual([
322
+ priorMessage,
323
+ transcript,
324
+ ]);
325
+ expect(runAgent).not.toHaveBeenCalled();
326
+ });
327
+
328
+ it.each(["interrupt", "tool-calls"] as const)(
329
+ "leaves a pending %s unchanged when transcripts await history",
330
+ async (reason) => {
331
+ const pending: ThreadAssistantMessage = {
332
+ id: "pending",
333
+ role: "assistant",
334
+ content: [
335
+ {
336
+ type: "tool-call",
337
+ toolCallId: "call-1",
338
+ toolName: "confirm",
339
+ args: {},
340
+ argsText: "{}",
341
+ },
342
+ ],
343
+ createdAt: new Date(0),
344
+ status: { type: "requires-action", reason },
345
+ metadata: {
346
+ unstable_state: null,
347
+ unstable_annotations: [],
348
+ unstable_data: [],
349
+ steps: [],
350
+ custom:
351
+ reason === "interrupt"
352
+ ? {
353
+ agui: {
354
+ interrupts: [{ id: "approval-1", reason: "confirmation" }],
355
+ },
356
+ }
357
+ : {},
358
+ },
359
+ };
360
+ const history = createHistory();
361
+ let finishLoad!: (
362
+ repository: Awaited<ReturnType<ThreadHistoryAdapter["load"]>>,
363
+ ) => void;
364
+ history.load.mockReturnValueOnce(
365
+ new Promise((resolve) => {
366
+ finishLoad = resolve;
367
+ }),
368
+ );
369
+ const voice = createVoiceAdapter();
370
+ const runAgent = vi.fn();
371
+ const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
372
+ const { result } = renderHook(() =>
373
+ useAgUiRuntime({
374
+ agent,
375
+ adapters: { voice: voice.adapter, history },
376
+ }),
377
+ );
378
+ const { user, assistant } = emitTranscripts(result.current, voice);
379
+ expect(history.append).not.toHaveBeenCalled();
380
+ await act(async () => {
381
+ finishLoad({
382
+ headId: pending.id,
383
+ messages: [{ parentId: null, message: pending }],
384
+ });
385
+ });
386
+ act(() => result.current.thread.disconnectVoice());
387
+
388
+ expect(result.current.thread.getState().messages).toEqual([
389
+ pending,
390
+ user,
391
+ assistant,
392
+ ]);
393
+ expect(result.current.thread.getState().messages[0]).toBe(pending);
394
+ expect(history.append).toHaveBeenCalledTimes(2);
395
+ expect(history.append).toHaveBeenNthCalledWith(1, {
396
+ parentId: pending.id,
397
+ message: user,
398
+ });
399
+ expect(runAgent).not.toHaveBeenCalled();
400
+ },
401
+ );
402
+ });