@assistant-ui/react-ag-ui 0.0.71 → 0.1.1
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/README.md +1 -1
- package/dist/agUiExtras.js +1 -2
- package/dist/hooks.js +1 -2
- package/dist/runtime/{AgUiThreadRuntimeCore.d.ts → AgUiThreadController.d.ts} +6 -12
- package/dist/runtime/AgUiThreadController.d.ts.map +1 -0
- package/dist/runtime/{AgUiThreadRuntimeCore.js → AgUiThreadController.js} +567 -194
- package/dist/runtime/AgUiThreadController.js.map +1 -0
- package/dist/runtime/adapter/conversions.d.ts +4 -4
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +120 -35
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/replay-content.d.ts +9 -0
- package/dist/runtime/adapter/replay-content.d.ts.map +1 -0
- package/dist/runtime/adapter/replay-content.js +185 -0
- package/dist/runtime/adapter/replay-content.js.map +1 -0
- package/dist/runtime/adapter/run-aggregator.d.ts +3 -5
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +48 -63
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +0 -24
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +2 -24
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +2 -0
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
- package/dist/runtime/adapter/tool-approval.js +23 -6
- package/dist/runtime/adapter/tool-approval.js.map +1 -1
- package/dist/runtime/agUiThreadState.d.ts +60 -0
- package/dist/runtime/agUiThreadState.d.ts.map +1 -0
- package/dist/runtime/agUiThreadState.js +94 -0
- package/dist/runtime/agUiThreadState.js.map +1 -0
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +46 -50
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/interrupt-internals.js +1 -2
- package/dist/runtime/logger.js +1 -2
- package/dist/runtime/mcp-tool-result.d.ts +3 -1
- package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
- package/dist/runtime/mcp-tool-result.js +7 -5
- package/dist/runtime/mcp-tool-result.js.map +1 -1
- package/dist/runtime/types.d.ts +11 -35
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js +1 -2
- package/dist/runtime/types.js.map +1 -1
- package/dist/useAgUiRuntime.js +3 -4
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +12 -11
- package/src/runtime/AgUiThreadController.deferred-results.test.ts +548 -0
- package/src/runtime/{AgUiThreadRuntimeCore.interrupt-cancel.test.ts → AgUiThreadController.interrupt-cancel.test.ts} +4 -4
- package/src/runtime/AgUiThreadController.replay.test.ts +3932 -0
- package/src/runtime/{AgUiThreadRuntimeCore.test.ts → AgUiThreadController.test.ts} +114 -6
- package/src/runtime/{AgUiThreadRuntimeCore.ts → AgUiThreadController.ts} +1037 -236
- package/src/runtime/adapter/conversions.content.test.ts +149 -5
- package/src/runtime/adapter/conversions.tools.test.ts +2 -1
- package/src/runtime/adapter/conversions.ts +249 -62
- package/src/runtime/adapter/replay-content.test.ts +235 -0
- package/src/runtime/adapter/replay-content.ts +306 -0
- package/src/runtime/adapter/run-aggregator.ts +79 -114
- package/src/runtime/adapter/subscriber.ts +1 -44
- package/src/runtime/adapter/tool-approval.ts +41 -3
- package/src/runtime/agUiThreadState.test.ts +224 -0
- package/src/runtime/agUiThreadState.ts +139 -0
- package/src/runtime/event-parser.ts +40 -58
- package/src/runtime/mcp-tool-result.ts +16 -2
- package/src/runtime/types.ts +16 -28
- package/src/useAgUiRuntime.fast-refresh.test.tsx +3 -3
- package/src/useAgUiRuntime.thread-switch.test.tsx +4 -4
- package/src/useAgUiRuntime.ts +3 -3
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +0 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +0 -1
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { AbstractAgent } from "@ag-ui/client";
|
|
3
|
+
import type {
|
|
4
|
+
AppendMessage,
|
|
5
|
+
ThreadAssistantMessage,
|
|
6
|
+
ThreadMessage,
|
|
7
|
+
} from "@assistant-ui/core";
|
|
8
|
+
import { AgUiThreadController } from "./AgUiThreadController";
|
|
9
|
+
import { toAgUiMessages } from "./adapter/conversions";
|
|
10
|
+
import { makeLogger } from "./logger";
|
|
11
|
+
|
|
12
|
+
type Subscriber = {
|
|
13
|
+
onMessagesSnapshotEvent?: (payload: { event: unknown }) => void;
|
|
14
|
+
onTextMessageStartEvent?: (payload: { event: unknown }) => void;
|
|
15
|
+
onTextMessageEndEvent?: (payload: { event: unknown }) => void;
|
|
16
|
+
onToolCallStartEvent?: (payload: { event: unknown }) => void;
|
|
17
|
+
onToolCallEndEvent?: (payload: { event: unknown }) => void;
|
|
18
|
+
onRunFinishedEvent?: (payload: { event: unknown }) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const userMessage = {
|
|
22
|
+
parentId: null,
|
|
23
|
+
role: "user",
|
|
24
|
+
content: [{ type: "text", text: "look up both" }],
|
|
25
|
+
} as unknown as AppendMessage;
|
|
26
|
+
|
|
27
|
+
function openToolTurn() {
|
|
28
|
+
let release!: () => void;
|
|
29
|
+
let onMessagesSnapshotEvent: Subscriber["onMessagesSnapshotEvent"];
|
|
30
|
+
const transportClosed = new Promise<void>((resolve) => {
|
|
31
|
+
release = resolve;
|
|
32
|
+
});
|
|
33
|
+
const runAgent = vi.fn(
|
|
34
|
+
async (
|
|
35
|
+
input: { runId: string; messages: unknown[] },
|
|
36
|
+
subscriber: Subscriber,
|
|
37
|
+
) => {
|
|
38
|
+
if (runAgent.mock.calls.length !== 1) {
|
|
39
|
+
subscriber.onRunFinishedEvent?.({
|
|
40
|
+
event: { type: "RUN_FINISHED", runId: input.runId },
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
onMessagesSnapshotEvent = subscriber.onMessagesSnapshotEvent;
|
|
45
|
+
for (const [messageId, toolCallId] of [
|
|
46
|
+
["assistant-a", "tool-a"],
|
|
47
|
+
["assistant-b", "tool-b"],
|
|
48
|
+
]) {
|
|
49
|
+
subscriber.onTextMessageStartEvent?.({
|
|
50
|
+
event: { type: "TEXT_MESSAGE_START", messageId },
|
|
51
|
+
});
|
|
52
|
+
subscriber.onTextMessageEndEvent?.({
|
|
53
|
+
event: { type: "TEXT_MESSAGE_END", messageId },
|
|
54
|
+
});
|
|
55
|
+
subscriber.onToolCallStartEvent?.({
|
|
56
|
+
event: {
|
|
57
|
+
type: "TOOL_CALL_START",
|
|
58
|
+
toolCallId,
|
|
59
|
+
toolCallName: "lookup",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
subscriber.onToolCallEndEvent?.({
|
|
63
|
+
event: { type: "TOOL_CALL_END", toolCallId },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
subscriber.onRunFinishedEvent?.({
|
|
67
|
+
event: { type: "RUN_FINISHED", runId: input.runId },
|
|
68
|
+
});
|
|
69
|
+
await transportClosed;
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
const core = new AgUiThreadController({
|
|
73
|
+
agent: { runAgent, abortRun: vi.fn() } as unknown as AbstractAgent,
|
|
74
|
+
logger: makeLogger(),
|
|
75
|
+
showThinking: false,
|
|
76
|
+
});
|
|
77
|
+
const sendMessagesSnapshot = (messages: unknown[]) => {
|
|
78
|
+
if (!onMessagesSnapshotEvent) {
|
|
79
|
+
throw new Error("runAgent did not provide onMessagesSnapshotEvent");
|
|
80
|
+
}
|
|
81
|
+
onMessagesSnapshotEvent({
|
|
82
|
+
event: { type: "MESSAGES_SNAPSHOT", messages },
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
return { core, runAgent, release, sendMessagesSnapshot };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function addResult(
|
|
89
|
+
core: AgUiThreadController,
|
|
90
|
+
messageId: string,
|
|
91
|
+
toolCallId: string,
|
|
92
|
+
) {
|
|
93
|
+
core.addToolResult({
|
|
94
|
+
messageId,
|
|
95
|
+
toolCallId,
|
|
96
|
+
toolName: "lookup",
|
|
97
|
+
result: `${toolCallId}-result`,
|
|
98
|
+
isError: false,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function withResolvedResults(
|
|
103
|
+
messages: readonly ThreadMessage[],
|
|
104
|
+
results: Readonly<Record<string, string>>,
|
|
105
|
+
): ThreadMessage[] {
|
|
106
|
+
return messages.map((message) => {
|
|
107
|
+
if (message.role !== "assistant") return message;
|
|
108
|
+
return {
|
|
109
|
+
...message,
|
|
110
|
+
content: message.content.map((part) => {
|
|
111
|
+
if (part.type !== "tool-call") return part;
|
|
112
|
+
const result = results[part.toolCallId];
|
|
113
|
+
return result === undefined
|
|
114
|
+
? part
|
|
115
|
+
: { ...part, result, isError: false };
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toolCallResults(core: AgUiThreadController) {
|
|
122
|
+
return core
|
|
123
|
+
.getMessages()
|
|
124
|
+
.flatMap((message) =>
|
|
125
|
+
message.role !== "assistant"
|
|
126
|
+
? []
|
|
127
|
+
: message.content.flatMap((part) =>
|
|
128
|
+
part.type === "tool-call"
|
|
129
|
+
? [{ toolCallId: part.toolCallId, result: part.result }]
|
|
130
|
+
: [],
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe("AgUiThreadController deferred tool results", () => {
|
|
136
|
+
it.each([
|
|
137
|
+
{
|
|
138
|
+
snapshot: "stale",
|
|
139
|
+
snapshotResults: undefined,
|
|
140
|
+
expectedResults: {
|
|
141
|
+
"tool-a": "tool-a-result",
|
|
142
|
+
"tool-b": "tool-b-result",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
snapshot: "fresh resolved",
|
|
147
|
+
snapshotResults: {
|
|
148
|
+
"tool-a": "fresh tool-a result",
|
|
149
|
+
"tool-b": "fresh tool-b result",
|
|
150
|
+
},
|
|
151
|
+
expectedResults: {
|
|
152
|
+
"tool-a": "fresh tool-a result",
|
|
153
|
+
"tool-b": "fresh tool-b result",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
])(
|
|
157
|
+
"preserves result authority when applying a same-parent $snapshot snapshot",
|
|
158
|
+
async ({ snapshotResults, expectedResults }) => {
|
|
159
|
+
const { core, runAgent, release } = openToolTurn();
|
|
160
|
+
const firstRun = core.append(userMessage);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const captured = core.getMessages();
|
|
164
|
+
const externalSnapshot = snapshotResults
|
|
165
|
+
? withResolvedResults(captured, snapshotResults)
|
|
166
|
+
: captured;
|
|
167
|
+
|
|
168
|
+
addResult(core, "assistant-a", "tool-a");
|
|
169
|
+
addResult(core, "assistant-b", "tool-b");
|
|
170
|
+
expect(core.getPendingToolCalls()).toBeNull();
|
|
171
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
172
|
+
|
|
173
|
+
core.applyExternalMessages(externalSnapshot);
|
|
174
|
+
|
|
175
|
+
expect(toolCallResults(core)).toEqual([
|
|
176
|
+
{ toolCallId: "tool-a", result: expectedResults["tool-a"] },
|
|
177
|
+
{ toolCallId: "tool-b", result: expectedResults["tool-b"] },
|
|
178
|
+
]);
|
|
179
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
180
|
+
} finally {
|
|
181
|
+
release();
|
|
182
|
+
await firstRun;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await vi.waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
|
|
186
|
+
expect(runAgent).toHaveBeenCalledTimes(2);
|
|
187
|
+
|
|
188
|
+
const wireMessages = runAgent.mock.calls[1]?.[0].messages ?? [];
|
|
189
|
+
const wireToolResults = wireMessages.filter(
|
|
190
|
+
(message) =>
|
|
191
|
+
typeof message === "object" &&
|
|
192
|
+
message !== null &&
|
|
193
|
+
"role" in message &&
|
|
194
|
+
message.role === "tool",
|
|
195
|
+
);
|
|
196
|
+
expect(wireToolResults).toHaveLength(2);
|
|
197
|
+
expect(wireToolResults).toEqual(
|
|
198
|
+
expect.arrayContaining([
|
|
199
|
+
expect.objectContaining({
|
|
200
|
+
toolCallId: "tool-a",
|
|
201
|
+
content: expectedResults["tool-a"],
|
|
202
|
+
}),
|
|
203
|
+
expect.objectContaining({
|
|
204
|
+
toolCallId: "tool-b",
|
|
205
|
+
content: expectedResults["tool-b"],
|
|
206
|
+
}),
|
|
207
|
+
]),
|
|
208
|
+
);
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
it("does not requeue a resolved result after a stale MESSAGES_SNAPSHOT", async () => {
|
|
213
|
+
const { core, runAgent, release, sendMessagesSnapshot } = openToolTurn();
|
|
214
|
+
const firstRun = core.append(userMessage);
|
|
215
|
+
let snapshotStatuses: (string | undefined)[] = [];
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const staleSnapshot = toAgUiMessages(core.getMessages());
|
|
219
|
+
addResult(core, "assistant-a", "tool-a");
|
|
220
|
+
addResult(core, "assistant-b", "tool-b");
|
|
221
|
+
sendMessagesSnapshot(staleSnapshot);
|
|
222
|
+
|
|
223
|
+
expect(toolCallResults(core)).toEqual([
|
|
224
|
+
{ toolCallId: "tool-a", result: "tool-a-result" },
|
|
225
|
+
{ toolCallId: "tool-b", result: "tool-b-result" },
|
|
226
|
+
]);
|
|
227
|
+
snapshotStatuses = core
|
|
228
|
+
.getMessages()
|
|
229
|
+
.filter(
|
|
230
|
+
(message): message is ThreadAssistantMessage =>
|
|
231
|
+
message.role === "assistant",
|
|
232
|
+
)
|
|
233
|
+
.map((message) => message.status?.type);
|
|
234
|
+
} finally {
|
|
235
|
+
release();
|
|
236
|
+
await firstRun;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await vi.waitFor(() => {
|
|
240
|
+
expect(runAgent).toHaveBeenCalledTimes(2);
|
|
241
|
+
expect(core.isRunning()).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
const continuation = runAgent.mock.calls[1]?.[0].messages ?? [];
|
|
244
|
+
const continuationToolResults = continuation.filter(
|
|
245
|
+
(message) =>
|
|
246
|
+
typeof message === "object" &&
|
|
247
|
+
message !== null &&
|
|
248
|
+
"role" in message &&
|
|
249
|
+
message.role === "tool",
|
|
250
|
+
);
|
|
251
|
+
expect(continuationToolResults).toHaveLength(2);
|
|
252
|
+
expect(continuationToolResults).toEqual(
|
|
253
|
+
expect.arrayContaining([
|
|
254
|
+
expect.objectContaining({
|
|
255
|
+
role: "tool",
|
|
256
|
+
toolCallId: "tool-a",
|
|
257
|
+
content: "tool-a-result",
|
|
258
|
+
}),
|
|
259
|
+
expect.objectContaining({
|
|
260
|
+
role: "tool",
|
|
261
|
+
toolCallId: "tool-b",
|
|
262
|
+
content: "tool-b-result",
|
|
263
|
+
}),
|
|
264
|
+
]),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
addResult(core, "assistant-a", "tool-a");
|
|
268
|
+
|
|
269
|
+
expect(runAgent).toHaveBeenCalledTimes(2);
|
|
270
|
+
expect(snapshotStatuses).toEqual(["complete", "complete"]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("does not continue a result owner whose snapshot keeps only its message id", async () => {
|
|
274
|
+
const { core, runAgent, release, sendMessagesSnapshot } = openToolTurn();
|
|
275
|
+
const firstRun = core.append(userMessage);
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const userSnapshot = toAgUiMessages(core.getMessages()).find(
|
|
279
|
+
(message) => message.role === "user",
|
|
280
|
+
);
|
|
281
|
+
if (!userSnapshot) throw new Error("missing user snapshot message");
|
|
282
|
+
|
|
283
|
+
addResult(core, "assistant-b", "tool-b");
|
|
284
|
+
expect(core.getPendingToolCalls()?.toolCallIds).toEqual(["tool-a"]);
|
|
285
|
+
|
|
286
|
+
sendMessagesSnapshot([
|
|
287
|
+
userSnapshot,
|
|
288
|
+
{ id: "assistant-b", role: "assistant", content: "snapshot text" },
|
|
289
|
+
]);
|
|
290
|
+
|
|
291
|
+
expect(core.getMessages().map((message) => message.id)).toContain(
|
|
292
|
+
"assistant-b",
|
|
293
|
+
);
|
|
294
|
+
expect(toolCallResults(core)).toEqual([]);
|
|
295
|
+
} finally {
|
|
296
|
+
release();
|
|
297
|
+
await firstRun;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const continuation = runAgent.mock.calls[1]?.[0].messages ?? [];
|
|
301
|
+
expect(
|
|
302
|
+
continuation.filter(
|
|
303
|
+
(message) =>
|
|
304
|
+
typeof message === "object" &&
|
|
305
|
+
message !== null &&
|
|
306
|
+
"role" in message &&
|
|
307
|
+
message.role === "tool",
|
|
308
|
+
),
|
|
309
|
+
).toEqual([]);
|
|
310
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("continues from an earlier resolved owner when the active call leaves the snapshot", async () => {
|
|
314
|
+
const { core, runAgent, release, sendMessagesSnapshot } = openToolTurn();
|
|
315
|
+
const firstRun = core.append(userMessage);
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
addResult(core, "assistant-a", "tool-a");
|
|
319
|
+
addResult(core, "assistant-b", "tool-b");
|
|
320
|
+
|
|
321
|
+
const snapshot = toAgUiMessages(core.getMessages()).flatMap((message) => {
|
|
322
|
+
if (message.role === "tool" && message.toolCallId === "tool-b") {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
if (message.role === "assistant" && message.id === "assistant-b") {
|
|
326
|
+
return [
|
|
327
|
+
{
|
|
328
|
+
id: message.id,
|
|
329
|
+
role: "assistant" as const,
|
|
330
|
+
content: "snapshot text",
|
|
331
|
+
},
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
return [message];
|
|
335
|
+
});
|
|
336
|
+
sendMessagesSnapshot(snapshot);
|
|
337
|
+
|
|
338
|
+
expect(toolCallResults(core)).toEqual([
|
|
339
|
+
{ toolCallId: "tool-a", result: "tool-a-result" },
|
|
340
|
+
]);
|
|
341
|
+
} finally {
|
|
342
|
+
release();
|
|
343
|
+
await firstRun;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
await vi.waitFor(() => {
|
|
347
|
+
expect(runAgent).toHaveBeenCalledTimes(2);
|
|
348
|
+
expect(core.isRunning()).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
const continuation = runAgent.mock.calls[1]?.[0].messages ?? [];
|
|
351
|
+
const continuationToolResults = continuation.filter(
|
|
352
|
+
(message) =>
|
|
353
|
+
typeof message === "object" &&
|
|
354
|
+
message !== null &&
|
|
355
|
+
"role" in message &&
|
|
356
|
+
message.role === "tool",
|
|
357
|
+
);
|
|
358
|
+
expect(continuationToolResults).toEqual([
|
|
359
|
+
expect.objectContaining({
|
|
360
|
+
role: "tool",
|
|
361
|
+
toolCallId: "tool-a",
|
|
362
|
+
content: "tool-a-result",
|
|
363
|
+
}),
|
|
364
|
+
]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("continues a surviving owner after a snapshot removes another pending tool", async () => {
|
|
368
|
+
const { core, runAgent, release } = openToolTurn();
|
|
369
|
+
const firstRun = core.append(userMessage);
|
|
370
|
+
expect(core.getPendingToolCalls()?.toolCallIds).toEqual([
|
|
371
|
+
"tool-a",
|
|
372
|
+
"tool-b",
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
addResult(core, "assistant-a", "tool-a");
|
|
376
|
+
const retained = core
|
|
377
|
+
.getMessages()
|
|
378
|
+
.filter((message) => message.id !== "assistant-b");
|
|
379
|
+
core.applyExternalMessages(retained);
|
|
380
|
+
release();
|
|
381
|
+
await firstRun;
|
|
382
|
+
await vi.waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
|
|
383
|
+
|
|
384
|
+
const messages = core.getMessages();
|
|
385
|
+
expect(
|
|
386
|
+
messages.find((message) => message.id === "assistant-b"),
|
|
387
|
+
).toBeUndefined();
|
|
388
|
+
expect(
|
|
389
|
+
(
|
|
390
|
+
messages.find(
|
|
391
|
+
(message) => message.id === "assistant-a",
|
|
392
|
+
) as ThreadAssistantMessage
|
|
393
|
+
).content,
|
|
394
|
+
).toContainEqual(
|
|
395
|
+
expect.objectContaining({
|
|
396
|
+
toolCallId: "tool-a",
|
|
397
|
+
result: "tool-a-result",
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
expect(runAgent.mock.calls[1]?.[0].messages).toContainEqual(
|
|
401
|
+
expect.objectContaining({ id: "assistant-a" }),
|
|
402
|
+
);
|
|
403
|
+
expect(runAgent.mock.calls[1]?.[0].messages).toContainEqual(
|
|
404
|
+
expect.objectContaining({
|
|
405
|
+
role: "tool",
|
|
406
|
+
toolCallId: "tool-a",
|
|
407
|
+
content: "tool-a-result",
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("keeps a second result owner when a snapshot removes the first", async () => {
|
|
413
|
+
const { core, runAgent, release } = openToolTurn();
|
|
414
|
+
const firstRun = core.append(userMessage);
|
|
415
|
+
addResult(core, "assistant-a", "tool-a");
|
|
416
|
+
addResult(core, "assistant-b", "tool-b");
|
|
417
|
+
const [user, , second] = core.getMessages();
|
|
418
|
+
core.applyExternalMessages([user!, second!] as ThreadMessage[]);
|
|
419
|
+
release();
|
|
420
|
+
await firstRun;
|
|
421
|
+
await vi.waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
|
|
422
|
+
|
|
423
|
+
expect(
|
|
424
|
+
core.getMessages().some((message) => message.id === "assistant-a"),
|
|
425
|
+
).toBe(false);
|
|
426
|
+
expect(
|
|
427
|
+
(
|
|
428
|
+
core
|
|
429
|
+
.getMessages()
|
|
430
|
+
.find(
|
|
431
|
+
(message) => message.id === "assistant-b",
|
|
432
|
+
) as ThreadAssistantMessage
|
|
433
|
+
).content,
|
|
434
|
+
).toContainEqual(
|
|
435
|
+
expect.objectContaining({
|
|
436
|
+
toolCallId: "tool-b",
|
|
437
|
+
result: "tool-b-result",
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
expect(runAgent.mock.calls[1]?.[0].messages).toContainEqual(
|
|
441
|
+
expect.objectContaining({ id: "assistant-b" }),
|
|
442
|
+
);
|
|
443
|
+
expect(runAgent.mock.calls[1]?.[0].messages).toContainEqual(
|
|
444
|
+
expect.objectContaining({
|
|
445
|
+
role: "tool",
|
|
446
|
+
toolCallId: "tool-b",
|
|
447
|
+
content: "tool-b-result",
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("keeps a result when a stale snapshot moves its owner to a new parent", async () => {
|
|
453
|
+
const { core, runAgent, release } = openToolTurn();
|
|
454
|
+
const firstRun = core.append(userMessage);
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
const captured = core.getMessages();
|
|
458
|
+
addResult(core, "assistant-a", "tool-a");
|
|
459
|
+
addResult(core, "assistant-b", "tool-b");
|
|
460
|
+
core.applyExternalMessages(
|
|
461
|
+
captured.filter((message) => message.id !== "assistant-a"),
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
expect(toolCallResults(core)).toEqual([
|
|
465
|
+
{ toolCallId: "tool-b", result: "tool-b-result" },
|
|
466
|
+
]);
|
|
467
|
+
expect(core.getPendingToolCalls()).toBeNull();
|
|
468
|
+
} finally {
|
|
469
|
+
release();
|
|
470
|
+
await firstRun;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
await vi.waitFor(() => {
|
|
474
|
+
expect(runAgent).toHaveBeenCalledTimes(2);
|
|
475
|
+
expect(core.isRunning()).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
const continuation = runAgent.mock.calls[1]?.[0].messages ?? [];
|
|
478
|
+
expect(
|
|
479
|
+
continuation.filter(
|
|
480
|
+
(message) =>
|
|
481
|
+
typeof message === "object" &&
|
|
482
|
+
message !== null &&
|
|
483
|
+
"role" in message &&
|
|
484
|
+
message.role === "tool",
|
|
485
|
+
),
|
|
486
|
+
).toEqual([
|
|
487
|
+
expect.objectContaining({
|
|
488
|
+
role: "tool",
|
|
489
|
+
toolCallId: "tool-b",
|
|
490
|
+
content: "tool-b-result",
|
|
491
|
+
}),
|
|
492
|
+
]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("does not continue when a snapshot removes every result owner", async () => {
|
|
496
|
+
const { core, runAgent, release } = openToolTurn();
|
|
497
|
+
const firstRun = core.append(userMessage);
|
|
498
|
+
addResult(core, "assistant-a", "tool-a");
|
|
499
|
+
core.applyExternalMessages(core.getMessages().slice(0, 1));
|
|
500
|
+
release();
|
|
501
|
+
await firstRun;
|
|
502
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("waits when a different tool in the turn is still pending", async () => {
|
|
506
|
+
const { core, runAgent, release } = openToolTurn();
|
|
507
|
+
const firstRun = core.append(userMessage);
|
|
508
|
+
addResult(core, "assistant-a", "tool-a");
|
|
509
|
+
release();
|
|
510
|
+
await firstRun;
|
|
511
|
+
|
|
512
|
+
expect(core.getPendingToolCalls()?.toolCallIds).toEqual(["tool-b"]);
|
|
513
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("does not continue a deferred result past a later interrupt", async () => {
|
|
517
|
+
const { core, runAgent, release } = openToolTurn();
|
|
518
|
+
const firstRun = core.append(userMessage);
|
|
519
|
+
addResult(core, "assistant-a", "tool-a");
|
|
520
|
+
const [user, first, second] = core.getMessages();
|
|
521
|
+
const interrupted = {
|
|
522
|
+
...second!,
|
|
523
|
+
status: {
|
|
524
|
+
type: "requires-action" as const,
|
|
525
|
+
reason: "interrupt" as const,
|
|
526
|
+
},
|
|
527
|
+
metadata: {
|
|
528
|
+
...second!.metadata,
|
|
529
|
+
custom: {
|
|
530
|
+
...second!.metadata.custom,
|
|
531
|
+
agui: {
|
|
532
|
+
interrupts: [
|
|
533
|
+
{ id: "gate", reason: "tool_call", toolCallId: "tool-b" },
|
|
534
|
+
],
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
} as ThreadAssistantMessage;
|
|
539
|
+
core.applyExternalMessages([user!, first!, interrupted]);
|
|
540
|
+
release();
|
|
541
|
+
await firstRun;
|
|
542
|
+
|
|
543
|
+
expect(core.getPendingInterrupts()?.interrupts).toContainEqual(
|
|
544
|
+
expect.objectContaining({ id: "gate" }),
|
|
545
|
+
);
|
|
546
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
547
|
+
});
|
|
548
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import type { AbstractAgent } from "@ag-ui/client";
|
|
3
3
|
import type { AppendMessage, ThreadAssistantMessage } from "@assistant-ui/core";
|
|
4
|
-
import {
|
|
4
|
+
import { AgUiThreadController } from "./AgUiThreadController";
|
|
5
5
|
import { makeLogger } from "./logger";
|
|
6
6
|
|
|
7
7
|
type EventSubscriber = {
|
|
@@ -76,7 +76,7 @@ function createOpenRun(finishWithInterrupt: boolean) {
|
|
|
76
76
|
},
|
|
77
77
|
);
|
|
78
78
|
const abortRun = vi.fn(() => releaseFirst());
|
|
79
|
-
const core = new
|
|
79
|
+
const core = new AgUiThreadController({
|
|
80
80
|
agent: { runAgent, abortRun } as unknown as AbstractAgent,
|
|
81
81
|
logger: makeLogger(),
|
|
82
82
|
showThinking: false,
|
|
@@ -84,7 +84,7 @@ function createOpenRun(finishWithInterrupt: boolean) {
|
|
|
84
84
|
return { core, runAgent, abortRun };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
describe("
|
|
87
|
+
describe("AgUiThreadController Stop after an interrupt", () => {
|
|
88
88
|
it("cancels a finished tool call when stopped before the stream closes", async () => {
|
|
89
89
|
let releaseFirst!: () => void;
|
|
90
90
|
const firstRunClosed = new Promise<void>((resolve) => {
|
|
@@ -110,7 +110,7 @@ describe("AgUiThreadRuntimeCore Stop after an interrupt", () => {
|
|
|
110
110
|
},
|
|
111
111
|
);
|
|
112
112
|
const abortRun = vi.fn(() => releaseFirst());
|
|
113
|
-
const core = new
|
|
113
|
+
const core = new AgUiThreadController({
|
|
114
114
|
agent: { runAgent, abortRun } as unknown as AbstractAgent,
|
|
115
115
|
logger: makeLogger(),
|
|
116
116
|
showThinking: false,
|