@assistant-ui/react-ag-ui 0.0.60 → 0.0.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agUiExtras.d.ts +1 -3
- package/dist/agUiExtras.d.ts.map +1 -1
- package/dist/hooks.d.ts +3 -5
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -0
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts +152 -144
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +141 -23
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +51 -53
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +65 -21
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +89 -88
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +35 -2
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +107 -108
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +3 -4
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
- package/dist/runtime/event-parser.d.ts +3 -5
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/interrupt-internals.d.ts +1 -3
- package/dist/runtime/interrupt-internals.d.ts.map +1 -1
- package/dist/runtime/logger.d.ts +2 -4
- package/dist/runtime/logger.d.ts.map +1 -1
- package/dist/runtime/mcp-tool-result.d.ts +4 -6
- package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
- package/dist/runtime/types.d.ts +237 -238
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.d.ts +13 -15
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +9 -2
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/runtime/AgUiThreadRuntimeCore.ts +239 -32
- package/src/runtime/adapter/conversions.content.test.ts +56 -0
- package/src/runtime/adapter/conversions.ts +112 -44
- package/src/runtime/adapter/run-aggregator.ts +53 -2
- package/src/useAgUiRuntime.history.test.tsx +93 -2
- package/src/useAgUiRuntime.thread-switch.test.tsx +84 -4
- package/src/useAgUiRuntime.ts +12 -3
- package/src/useAgUiRuntime.voice.test.tsx +402 -0
package/src/useAgUiRuntime.ts
CHANGED
|
@@ -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.
|
|
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
|
+
});
|