@assistant-ui/react-ag-ui 0.1.2 → 0.1.4
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/runtime/AgUiThreadController.d.ts +3 -0
- package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadController.js +124 -14
- package/dist/runtime/AgUiThreadController.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +8 -0
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/package.json +5 -4
- package/src/runtime/AgUiThreadController.snapshot-head-history.test.ts +226 -0
- package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +180 -1
- package/src/runtime/AgUiThreadController.snapshot-nested-settle.test.ts +210 -0
- package/src/runtime/AgUiThreadController.test.ts +133 -1
- package/src/runtime/AgUiThreadController.ts +207 -22
- package/src/runtime/adapter/run-aggregator.ts +11 -0
- package/src/useAgUiRuntime.cloud-run-leases.test.tsx +384 -0
- package/src/useAgUiRuntime.state.test.tsx +87 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { AppendMessage } from "@assistant-ui/core";
|
|
3
|
+
import { HttpAgent } from "@ag-ui/client";
|
|
4
|
+
import { AgUiThreadController } from "./AgUiThreadController";
|
|
5
|
+
import { makeLogger } from "./logger";
|
|
6
|
+
|
|
7
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
8
|
+
const settle = async () => {
|
|
9
|
+
for (let i = 0; i < 8; i++) await tick();
|
|
10
|
+
};
|
|
11
|
+
const userMessage = {
|
|
12
|
+
role: "user",
|
|
13
|
+
content: [{ type: "text", text: "hello" }],
|
|
14
|
+
attachments: [],
|
|
15
|
+
metadata: { custom: {} },
|
|
16
|
+
createdAt: new Date(0),
|
|
17
|
+
parentId: null,
|
|
18
|
+
sourceId: null,
|
|
19
|
+
runConfig: {},
|
|
20
|
+
startRun: true,
|
|
21
|
+
} as unknown as AppendMessage;
|
|
22
|
+
|
|
23
|
+
type Row = { id: string; status: string | undefined };
|
|
24
|
+
|
|
25
|
+
// Follows the ThreadHistoryAdapter contract: both writes upsert by message
|
|
26
|
+
// id, and only append moves the stored head.
|
|
27
|
+
function createHistory(writes: string[]) {
|
|
28
|
+
const items = new Map<string, any>();
|
|
29
|
+
let headId: string | null = null;
|
|
30
|
+
const store = (item: any, moveHead: boolean) => {
|
|
31
|
+
if (moveHead) headId = item.message.id;
|
|
32
|
+
items.set(item.message.id, structuredClone(item));
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
rows: () =>
|
|
36
|
+
new Map<string, Row>(
|
|
37
|
+
[...items.values()].map((item) => [
|
|
38
|
+
item.message.id,
|
|
39
|
+
{ id: item.message.id, status: item.message.status?.type },
|
|
40
|
+
]),
|
|
41
|
+
),
|
|
42
|
+
load: async () => ({ headId, messages: [...items.values()] }),
|
|
43
|
+
append: async (item: any) => {
|
|
44
|
+
writes.push(`append ${item.message.id}`);
|
|
45
|
+
store(item, true);
|
|
46
|
+
},
|
|
47
|
+
update: async (item: any) => {
|
|
48
|
+
writes.push(`update ${item.message.id}`);
|
|
49
|
+
store(item, false);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function reload(history: ReturnType<typeof createHistory>) {
|
|
55
|
+
const core = new AgUiThreadController({
|
|
56
|
+
agent: new HttpAgent({ url: "https://agent.test" }),
|
|
57
|
+
logger: makeLogger(),
|
|
58
|
+
showThinking: false,
|
|
59
|
+
history: history as any,
|
|
60
|
+
});
|
|
61
|
+
await core.__internal_load();
|
|
62
|
+
return core.getMessages().map((m: any) => `${m.id}:${m.status?.type ?? "-"}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function run(events: (requestMessages: any[]) => object[][]) {
|
|
66
|
+
const writes: string[] = [];
|
|
67
|
+
const history = createHistory(writes);
|
|
68
|
+
let write!: (e: object) => void;
|
|
69
|
+
let close!: () => void;
|
|
70
|
+
let requestMessages: any[] = [];
|
|
71
|
+
const agent = new HttpAgent({
|
|
72
|
+
url: "https://agent.test",
|
|
73
|
+
fetch: async (_url: unknown, init: RequestInit) => {
|
|
74
|
+
const encoder = new TextEncoder();
|
|
75
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
76
|
+
const body = new ReadableStream<Uint8Array>({
|
|
77
|
+
start: (c) => void (controller = c),
|
|
78
|
+
});
|
|
79
|
+
requestMessages = JSON.parse(init.body as string).messages ?? [];
|
|
80
|
+
write = (e) =>
|
|
81
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
82
|
+
close = () => controller.close();
|
|
83
|
+
return new Response(body, {
|
|
84
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const core = new AgUiThreadController({
|
|
89
|
+
agent,
|
|
90
|
+
logger: makeLogger(),
|
|
91
|
+
showThinking: false,
|
|
92
|
+
history: history as any,
|
|
93
|
+
});
|
|
94
|
+
const sending = core.append(userMessage).catch(() => {});
|
|
95
|
+
for (let i = 0; i < 50 && !write; i++) await tick();
|
|
96
|
+
for (const batch of events(requestMessages)) {
|
|
97
|
+
for (const e of batch) write(e);
|
|
98
|
+
await settle();
|
|
99
|
+
}
|
|
100
|
+
close();
|
|
101
|
+
await sending;
|
|
102
|
+
await settle();
|
|
103
|
+
const shown = core
|
|
104
|
+
.getMessages()
|
|
105
|
+
.map((m: any) => `${m.id}:${m.status?.type ?? "-"}`);
|
|
106
|
+
return {
|
|
107
|
+
rows: history.rows(),
|
|
108
|
+
writes,
|
|
109
|
+
shown,
|
|
110
|
+
reloaded: await reload(history),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const start = [
|
|
115
|
+
{ type: "RUN_STARTED", threadId: "t", runId: "r" },
|
|
116
|
+
{ type: "TEXT_MESSAGE_START", messageId: "answer", role: "assistant" },
|
|
117
|
+
{ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a1" },
|
|
118
|
+
];
|
|
119
|
+
const end = { type: "TEXT_MESSAGE_END", messageId: "answer" };
|
|
120
|
+
const moveSnapshot = (req: any[]) => ({
|
|
121
|
+
type: "MESSAGES_SNAPSHOT",
|
|
122
|
+
messages: [
|
|
123
|
+
...req,
|
|
124
|
+
{ id: "answer", role: "assistant", content: "a1" },
|
|
125
|
+
{ id: "other", role: "assistant", content: "o" },
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("ag-ui: a snapshot's new head and the history adapter", () => {
|
|
130
|
+
it("stores the new head after one head-move snapshot and RUN_FINISHED", async () => {
|
|
131
|
+
const { rows, writes, shown, reloaded } = await run((req) => [
|
|
132
|
+
start,
|
|
133
|
+
[moveSnapshot(req)],
|
|
134
|
+
[end, { type: "RUN_FINISHED", threadId: "t", runId: "r" }],
|
|
135
|
+
]);
|
|
136
|
+
expect(shown).toContain("other:complete");
|
|
137
|
+
expect(rows.get("other")?.status, writes.join(", ")).toBe("complete");
|
|
138
|
+
expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("stores the new head after one head-move snapshot and RUN_ERROR", async () => {
|
|
142
|
+
const { rows, writes, shown, reloaded } = await run((req) => [
|
|
143
|
+
start,
|
|
144
|
+
[moveSnapshot(req)],
|
|
145
|
+
[{ type: "RUN_ERROR", message: "boom" }],
|
|
146
|
+
]);
|
|
147
|
+
// Which message takes the error depends on whether the run moved onto
|
|
148
|
+
// "other"; either way history holds what the thread shows.
|
|
149
|
+
const other = shown.find((entry) => entry.startsWith("other:"));
|
|
150
|
+
expect(["other:incomplete", "other:complete"]).toContain(other);
|
|
151
|
+
expect(`other:${rows.get("other")?.status}`, writes.join(", ")).toBe(other);
|
|
152
|
+
expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("stores a snapshot-only answer that a second snapshot repeats", async () => {
|
|
156
|
+
const snapshot = (req: any[]) => ({
|
|
157
|
+
type: "MESSAGES_SNAPSHOT",
|
|
158
|
+
messages: [...req, { id: "only", role: "assistant", content: "o" }],
|
|
159
|
+
});
|
|
160
|
+
const { rows, writes, shown, reloaded } = await run((req) => [
|
|
161
|
+
[{ type: "RUN_STARTED", threadId: "t", runId: "r" }, snapshot(req)],
|
|
162
|
+
[snapshot(req)],
|
|
163
|
+
[{ type: "RUN_FINISHED", threadId: "t", runId: "r" }],
|
|
164
|
+
]);
|
|
165
|
+
expect(rows.get("only")?.status, writes.join(", ")).toBe("complete");
|
|
166
|
+
expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("stores an answer that arrives only in a snapshot", async () => {
|
|
170
|
+
const { rows, writes, shown, reloaded } = await run((req) => [
|
|
171
|
+
[
|
|
172
|
+
{ type: "RUN_STARTED", threadId: "t", runId: "r" },
|
|
173
|
+
{
|
|
174
|
+
type: "MESSAGES_SNAPSHOT",
|
|
175
|
+
messages: [...req, { id: "only", role: "assistant", content: "o" }],
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
[{ type: "RUN_FINISHED", threadId: "t", runId: "r" }],
|
|
179
|
+
]);
|
|
180
|
+
expect(shown).toContain("only:complete");
|
|
181
|
+
expect(rows.get("only")?.status, writes.join(", ")).toBe("complete");
|
|
182
|
+
expect(writes.filter((w) => w.endsWith(" only"))).toHaveLength(1);
|
|
183
|
+
expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("keeps the streamed answer as the head after a snapshot adds an earlier step", async () => {
|
|
187
|
+
const { writes, shown, reloaded } = await run((req) => [
|
|
188
|
+
start,
|
|
189
|
+
[
|
|
190
|
+
{
|
|
191
|
+
type: "MESSAGES_SNAPSHOT",
|
|
192
|
+
messages: [
|
|
193
|
+
...req,
|
|
194
|
+
{ id: "step", role: "assistant", content: "s" },
|
|
195
|
+
{ id: "answer", role: "assistant", content: "a1" },
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
[end, { type: "RUN_FINISHED", threadId: "t", runId: "r" }],
|
|
200
|
+
]);
|
|
201
|
+
expect(shown.slice(1)).toEqual(["step:complete", "answer:complete"]);
|
|
202
|
+
expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("control: a second snapshot after the head move stores the new head", async () => {
|
|
206
|
+
const { rows, writes, shown, reloaded } = await run((req) => [
|
|
207
|
+
start,
|
|
208
|
+
[moveSnapshot(req)],
|
|
209
|
+
[moveSnapshot(req)],
|
|
210
|
+
[end, { type: "RUN_FINISHED", threadId: "t", runId: "r" }],
|
|
211
|
+
]);
|
|
212
|
+
expect(rows.get("other")?.status, JSON.stringify({ writes, shown })).toBe(
|
|
213
|
+
"complete",
|
|
214
|
+
);
|
|
215
|
+
expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("control: the streamed answer without a snapshot is stored", async () => {
|
|
219
|
+
const { rows, shown, reloaded } = await run(() => [
|
|
220
|
+
start,
|
|
221
|
+
[end, { type: "RUN_FINISHED", threadId: "t", runId: "r" }],
|
|
222
|
+
]);
|
|
223
|
+
expect(rows.get("answer")?.status).toBe("complete");
|
|
224
|
+
expect(reloaded).toEqual(shown);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -58,9 +58,12 @@ const answerRecord = {
|
|
|
58
58
|
],
|
|
59
59
|
};
|
|
60
60
|
const otherRecord = { id: "other", role: "assistant", content: "Next step." };
|
|
61
|
-
const
|
|
61
|
+
const taskCallEvents = [
|
|
62
62
|
{ type: "TOOL_CALL_START", toolCallId: "task", toolCallName: "task" },
|
|
63
63
|
{ type: "TOOL_CALL_END", toolCallId: "task" },
|
|
64
|
+
];
|
|
65
|
+
const taskAndSubagentEvents = [
|
|
66
|
+
...taskCallEvents,
|
|
64
67
|
{
|
|
65
68
|
type: "SUBAGENT_STARTED",
|
|
66
69
|
subagentRunId: "sub",
|
|
@@ -106,7 +109,53 @@ function statuses(core: AgUiThreadController) {
|
|
|
106
109
|
return { answer: answer.status?.type, subagent: nested?.status?.type };
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
function taskPartFor(core: AgUiThreadController, messageId: string) {
|
|
113
|
+
const message = core.getMessages().find(({ id }) => id === messageId);
|
|
114
|
+
if (message?.role !== "assistant") return undefined;
|
|
115
|
+
return message.content.find(
|
|
116
|
+
(part) => part.type === "tool-call" && part.toolCallId === "task",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
describe("AgUiThreadController snapshot head moves", () => {
|
|
121
|
+
it("reopens a settled snapshot-only assistant when a later snapshot adds a tool call", async () => {
|
|
122
|
+
const answer = {
|
|
123
|
+
id: "m1",
|
|
124
|
+
role: "assistant",
|
|
125
|
+
content: "Let me ask.",
|
|
126
|
+
};
|
|
127
|
+
const core = createController((wire) => [
|
|
128
|
+
start,
|
|
129
|
+
{ type: "MESSAGES_SNAPSHOT", messages: [...wire.messages, answer] },
|
|
130
|
+
{
|
|
131
|
+
type: "MESSAGES_SNAPSHOT",
|
|
132
|
+
messages: [
|
|
133
|
+
...wire.messages,
|
|
134
|
+
{
|
|
135
|
+
...answer,
|
|
136
|
+
toolCalls: [
|
|
137
|
+
{
|
|
138
|
+
id: "ask",
|
|
139
|
+
type: "function",
|
|
140
|
+
function: { name: "ask_user", arguments: "{}" },
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
finished,
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
await core.append(userMessage);
|
|
150
|
+
|
|
151
|
+
expect(
|
|
152
|
+
core.getMessages().find(({ id }) => id === "m1")?.status,
|
|
153
|
+
).toMatchObject({
|
|
154
|
+
type: "requires-action",
|
|
155
|
+
reason: "tool-calls",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
110
159
|
it("settles the moved-off answer when the snapshot has no tool call", async () => {
|
|
111
160
|
const core = createController((wire) => [
|
|
112
161
|
start,
|
|
@@ -143,6 +192,136 @@ describe("AgUiThreadController snapshot head moves", () => {
|
|
|
143
192
|
});
|
|
144
193
|
});
|
|
145
194
|
|
|
195
|
+
it("keeps a moved-off answer settled and owns the result after repeated snapshots", async () => {
|
|
196
|
+
const core = createController((wire) => [
|
|
197
|
+
start,
|
|
198
|
+
...answerText,
|
|
199
|
+
...taskCallEvents,
|
|
200
|
+
snapshot(wire, otherRecord),
|
|
201
|
+
snapshot(wire, otherRecord),
|
|
202
|
+
taskResult,
|
|
203
|
+
finished,
|
|
204
|
+
]);
|
|
205
|
+
await core.append(userMessage);
|
|
206
|
+
|
|
207
|
+
expect(core.isRunning()).toBe(false);
|
|
208
|
+
expect(statuses(core).answer).toBe("complete");
|
|
209
|
+
expect(
|
|
210
|
+
core.getMessages().find(({ id }) => id === "other")?.status?.type,
|
|
211
|
+
).toBe("complete");
|
|
212
|
+
expect(taskPartFor(core, "answer")).toMatchObject({ result: "done" });
|
|
213
|
+
expect(taskPartFor(core, "other")).toBeUndefined();
|
|
214
|
+
expect(
|
|
215
|
+
core.getMessages().find(({ id }) => id === "answer")?.content,
|
|
216
|
+
).toEqual([
|
|
217
|
+
{ type: "text", text: "Let me check." },
|
|
218
|
+
{
|
|
219
|
+
type: "tool-call",
|
|
220
|
+
toolCallId: "task",
|
|
221
|
+
toolName: "task",
|
|
222
|
+
args: {},
|
|
223
|
+
argsText: "{}",
|
|
224
|
+
result: "done",
|
|
225
|
+
unstable_toolMessageId: "tool-task",
|
|
226
|
+
},
|
|
227
|
+
]);
|
|
228
|
+
expect(
|
|
229
|
+
core.getMessages().find(({ id }) => id === "other")?.content,
|
|
230
|
+
).toEqual([{ type: "text", text: "Next step." }]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("preserves full content and settled statuses with a sub-agent after repeated snapshots", async () => {
|
|
234
|
+
const core = createController((wire) => [
|
|
235
|
+
start,
|
|
236
|
+
...answerText,
|
|
237
|
+
...taskAndSubagentEvents,
|
|
238
|
+
snapshot(wire, otherRecord),
|
|
239
|
+
snapshot(wire, otherRecord),
|
|
240
|
+
subFinished,
|
|
241
|
+
taskResult,
|
|
242
|
+
finished,
|
|
243
|
+
]);
|
|
244
|
+
await core.append(userMessage);
|
|
245
|
+
|
|
246
|
+
expect(core.isRunning()).toBe(false);
|
|
247
|
+
expect(statuses(core)).toEqual({
|
|
248
|
+
answer: "complete",
|
|
249
|
+
subagent: "complete",
|
|
250
|
+
});
|
|
251
|
+
expect(
|
|
252
|
+
core.getMessages().find(({ id }) => id === "other")?.status?.type,
|
|
253
|
+
).toBe("complete");
|
|
254
|
+
expect(
|
|
255
|
+
core.getMessages().find(({ id }) => id === "answer")?.content,
|
|
256
|
+
).toEqual([
|
|
257
|
+
{ type: "text", text: "Let me check." },
|
|
258
|
+
{
|
|
259
|
+
type: "tool-call",
|
|
260
|
+
toolCallId: "task",
|
|
261
|
+
toolName: "task",
|
|
262
|
+
args: {},
|
|
263
|
+
argsText: "{}",
|
|
264
|
+
result: "done",
|
|
265
|
+
unstable_toolMessageId: "tool-task",
|
|
266
|
+
messages: [
|
|
267
|
+
expect.objectContaining({
|
|
268
|
+
id: "sub",
|
|
269
|
+
role: "assistant",
|
|
270
|
+
status: { type: "complete", reason: "unknown" },
|
|
271
|
+
content: [{ type: "text", text: "working" }],
|
|
272
|
+
}),
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
]);
|
|
276
|
+
expect(
|
|
277
|
+
core.getMessages().find(({ id }) => id === "other")?.content,
|
|
278
|
+
).toEqual([{ type: "text", text: "Next step." }]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it.each([false, true])(
|
|
282
|
+
"preserves current text when the moved result arrives during streaming: %s",
|
|
283
|
+
async (duringStreaming) => {
|
|
284
|
+
const core = createController((wire) => [
|
|
285
|
+
start,
|
|
286
|
+
...answerText,
|
|
287
|
+
...taskCallEvents,
|
|
288
|
+
snapshot(wire, otherRecord),
|
|
289
|
+
snapshot(wire, otherRecord),
|
|
290
|
+
...(!duringStreaming ? [taskResult] : []),
|
|
291
|
+
{ type: "TEXT_MESSAGE_START", messageId: "other", role: "assistant" },
|
|
292
|
+
{ type: "TEXT_MESSAGE_CONTENT", messageId: "other", delta: " Before." },
|
|
293
|
+
...(duringStreaming ? [taskResult] : []),
|
|
294
|
+
{ type: "TEXT_MESSAGE_CONTENT", messageId: "other", delta: " After." },
|
|
295
|
+
{ type: "TEXT_MESSAGE_END", messageId: "other" },
|
|
296
|
+
finished,
|
|
297
|
+
]);
|
|
298
|
+
await core.append(userMessage);
|
|
299
|
+
|
|
300
|
+
expect(core.isRunning()).toBe(false);
|
|
301
|
+
expect(statuses(core).answer).toBe("complete");
|
|
302
|
+
expect(
|
|
303
|
+
core.getMessages().find(({ id }) => id === "answer")?.content,
|
|
304
|
+
).toEqual([
|
|
305
|
+
{ type: "text", text: "Let me check." },
|
|
306
|
+
{
|
|
307
|
+
type: "tool-call",
|
|
308
|
+
toolCallId: "task",
|
|
309
|
+
toolName: "task",
|
|
310
|
+
args: {},
|
|
311
|
+
argsText: "{}",
|
|
312
|
+
result: "done",
|
|
313
|
+
unstable_toolMessageId: "tool-task",
|
|
314
|
+
},
|
|
315
|
+
]);
|
|
316
|
+
expect(core.getMessages().find(({ id }) => id === "other")).toMatchObject(
|
|
317
|
+
{
|
|
318
|
+
status: { type: "complete" },
|
|
319
|
+
content: [{ type: "text", text: "Next step. Before. After." }],
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
|
|
146
325
|
it("does not show a complete answer with a running sub-agent after a result snapshot", async () => {
|
|
147
326
|
const core = createController((wire) => [
|
|
148
327
|
start,
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { AppendMessage } from "@assistant-ui/core";
|
|
3
|
+
import { HttpAgent } from "@ag-ui/client";
|
|
4
|
+
import { AgUiThreadController } from "./AgUiThreadController";
|
|
5
|
+
import { makeLogger } from "./logger";
|
|
6
|
+
|
|
7
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
8
|
+
const settle = async () => {
|
|
9
|
+
for (let i = 0; i < 8; i++) await tick();
|
|
10
|
+
};
|
|
11
|
+
const userMessage = {
|
|
12
|
+
role: "user",
|
|
13
|
+
content: [{ type: "text", text: "hello" }],
|
|
14
|
+
attachments: [],
|
|
15
|
+
metadata: { custom: {} },
|
|
16
|
+
createdAt: new Date(0),
|
|
17
|
+
parentId: null,
|
|
18
|
+
sourceId: null,
|
|
19
|
+
runConfig: {},
|
|
20
|
+
startRun: true,
|
|
21
|
+
} as unknown as AppendMessage;
|
|
22
|
+
|
|
23
|
+
type End = "close" | "error" | "stop" | "finish";
|
|
24
|
+
|
|
25
|
+
async function run(end: End, withSnapshot = true, history?: unknown) {
|
|
26
|
+
let write!: (e: object) => void;
|
|
27
|
+
let close!: () => void;
|
|
28
|
+
let requestMessages: any[] = [];
|
|
29
|
+
const agent = new HttpAgent({
|
|
30
|
+
url: "https://agent.test",
|
|
31
|
+
fetch: async (_url: unknown, init: RequestInit) => {
|
|
32
|
+
const encoder = new TextEncoder();
|
|
33
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
34
|
+
let closed = false;
|
|
35
|
+
const body = new ReadableStream<Uint8Array>({
|
|
36
|
+
start: (c) => void (controller = c),
|
|
37
|
+
});
|
|
38
|
+
requestMessages = JSON.parse(init.body as string).messages ?? [];
|
|
39
|
+
write = (e) => {
|
|
40
|
+
if (!closed)
|
|
41
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
42
|
+
};
|
|
43
|
+
close = () => {
|
|
44
|
+
if (closed) return;
|
|
45
|
+
closed = true;
|
|
46
|
+
controller.close();
|
|
47
|
+
};
|
|
48
|
+
init.signal?.addEventListener("abort", () => {
|
|
49
|
+
if (closed) return;
|
|
50
|
+
closed = true;
|
|
51
|
+
try {
|
|
52
|
+
controller.error(new DOMException("aborted", "AbortError"));
|
|
53
|
+
} catch {}
|
|
54
|
+
});
|
|
55
|
+
return new Response(body, {
|
|
56
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const core = new AgUiThreadController({
|
|
61
|
+
agent,
|
|
62
|
+
logger: makeLogger(),
|
|
63
|
+
showThinking: false,
|
|
64
|
+
...(history ? { history: history as any } : {}),
|
|
65
|
+
});
|
|
66
|
+
const sending = core.append(userMessage).catch(() => {});
|
|
67
|
+
for (let i = 0; i < 50 && !write; i++) await tick();
|
|
68
|
+
const events: object[] = [
|
|
69
|
+
{ type: "RUN_STARTED", threadId: "t", runId: "r" },
|
|
70
|
+
{ type: "TEXT_MESSAGE_START", messageId: "answer", role: "assistant" },
|
|
71
|
+
{ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a1" },
|
|
72
|
+
{ type: "TEXT_MESSAGE_END", messageId: "answer" },
|
|
73
|
+
{ type: "TOOL_CALL_START", toolCallId: "task", toolCallName: "task" },
|
|
74
|
+
{ type: "TOOL_CALL_END", toolCallId: "task" },
|
|
75
|
+
{
|
|
76
|
+
type: "SUBAGENT_STARTED",
|
|
77
|
+
subagentRunId: "sub",
|
|
78
|
+
name: "worker",
|
|
79
|
+
parentToolCallId: "task",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
type: "TOOL_CALL_START",
|
|
83
|
+
toolCallId: "ncall",
|
|
84
|
+
toolCallName: "search",
|
|
85
|
+
subagentRunId: "sub",
|
|
86
|
+
},
|
|
87
|
+
{ type: "TOOL_CALL_END", toolCallId: "ncall", subagentRunId: "sub" },
|
|
88
|
+
{ type: "SUBAGENT_FINISHED", subagentRunId: "sub" },
|
|
89
|
+
];
|
|
90
|
+
for (const e of events) write(e);
|
|
91
|
+
await settle();
|
|
92
|
+
if (withSnapshot) {
|
|
93
|
+
write({
|
|
94
|
+
type: "MESSAGES_SNAPSHOT",
|
|
95
|
+
messages: [
|
|
96
|
+
...requestMessages,
|
|
97
|
+
{
|
|
98
|
+
id: "answer",
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: "a1",
|
|
101
|
+
toolCalls: [
|
|
102
|
+
{
|
|
103
|
+
id: "task",
|
|
104
|
+
type: "function",
|
|
105
|
+
function: { name: "task", arguments: "{}" },
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "s-call",
|
|
111
|
+
role: "assistant",
|
|
112
|
+
content: "",
|
|
113
|
+
subagentRunId: "sub",
|
|
114
|
+
toolCalls: [
|
|
115
|
+
{
|
|
116
|
+
id: "ncall",
|
|
117
|
+
type: "function",
|
|
118
|
+
function: { name: "search", arguments: "{}" },
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
await settle();
|
|
125
|
+
}
|
|
126
|
+
const before = nested(core);
|
|
127
|
+
if (end === "close") close();
|
|
128
|
+
else if (end === "error")
|
|
129
|
+
write({ type: "RUN_ERROR", message: "server failed" });
|
|
130
|
+
else if (end === "finish")
|
|
131
|
+
write({ type: "RUN_FINISHED", threadId: "t", runId: "r" });
|
|
132
|
+
else await core.cancel();
|
|
133
|
+
await settle();
|
|
134
|
+
close();
|
|
135
|
+
await sending;
|
|
136
|
+
await settle();
|
|
137
|
+
return { before, after: nested(core) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function nested(core: AgUiThreadController) {
|
|
141
|
+
const answer = core.getMessages().find((m: any) => m.id === "answer") as any;
|
|
142
|
+
const call = answer?.content.find(
|
|
143
|
+
(p: any) => p.type === "tool-call" && p.toolCallId === "task",
|
|
144
|
+
);
|
|
145
|
+
const sub = call?.messages?.find((m: any) => m.id === "sub");
|
|
146
|
+
return {
|
|
147
|
+
answer: answer?.status?.type,
|
|
148
|
+
sub: sub?.status?.type,
|
|
149
|
+
subReason: sub?.status?.reason,
|
|
150
|
+
ncallOpen: sub?.content.some(
|
|
151
|
+
(p: any) => p.toolCallId === "ncall" && p.result === undefined,
|
|
152
|
+
),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe("ag-ui: a nested message waiting on its own call after the run ends", () => {
|
|
157
|
+
for (const end of ["close", "error", "stop"] as const) {
|
|
158
|
+
it(`settles the nested message when the run ends by ${end}`, async () => {
|
|
159
|
+
const { before, after } = await run(end);
|
|
160
|
+
// the snapshot imported the sub-agent's message waiting on ncall
|
|
161
|
+
expect(before.sub, JSON.stringify(before)).toBe("requires-action");
|
|
162
|
+
expect(after.answer, JSON.stringify(after)).toBe("incomplete");
|
|
163
|
+
expect(after.sub, JSON.stringify(after)).not.toBe("requires-action");
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
it("control: after RUN_FINISHED the nested call stays open and is not settled as an error", async () => {
|
|
168
|
+
const { after } = await run("finish");
|
|
169
|
+
expect(after.answer, JSON.stringify(after)).toBe("requires-action");
|
|
170
|
+
expect(after.ncallOpen, JSON.stringify(after)).toBe(true);
|
|
171
|
+
expect(after.subReason, JSON.stringify(after)).not.toBe("error");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("control: without the snapshot the nested message is the stream's (complete) and settles", async () => {
|
|
175
|
+
const { before, after } = await run("close", false);
|
|
176
|
+
expect(before.sub, JSON.stringify(before)).toBe("complete");
|
|
177
|
+
expect(after.sub, JSON.stringify(after)).not.toBe("requires-action");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("appends the answer again when its first append fails under a queued update", async () => {
|
|
181
|
+
const writes: string[] = [];
|
|
182
|
+
let headId: string | null = null;
|
|
183
|
+
const rows = new Set<string>();
|
|
184
|
+
let failed = false;
|
|
185
|
+
const history = {
|
|
186
|
+
load: async () => ({ headId: null, messages: [] }),
|
|
187
|
+
append: async (item: any) => {
|
|
188
|
+
const id = item.message.id;
|
|
189
|
+
writes.push(`append ${id}`);
|
|
190
|
+
if (id === "answer" && !failed) {
|
|
191
|
+
failed = true;
|
|
192
|
+
await tick();
|
|
193
|
+
throw new Error("write failed");
|
|
194
|
+
}
|
|
195
|
+
rows.add(id);
|
|
196
|
+
headId = id;
|
|
197
|
+
},
|
|
198
|
+
// Follows the ThreadHistoryAdapter contract: update does not move the head.
|
|
199
|
+
update: async (item: any) => {
|
|
200
|
+
writes.push(`update ${item.message.id}`);
|
|
201
|
+
rows.add(item.message.id);
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
const { after } = await run("error", true, history);
|
|
205
|
+
expect(after.sub, JSON.stringify(after)).not.toBe("requires-action");
|
|
206
|
+
expect(writes, writes.join(", ")).not.toContain("update answer");
|
|
207
|
+
expect(rows.has("answer"), writes.join(", ")).toBe(true);
|
|
208
|
+
expect(headId, writes.join(", ")).toBe("answer");
|
|
209
|
+
});
|
|
210
|
+
});
|