@assistant-ui/react-ag-ui 0.1.5 → 0.1.7
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.map +1 -1
- package/dist/runtime/AgUiThreadController.js +80 -13
- package/dist/runtime/AgUiThreadController.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +37 -31
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +363 -218
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/package.json +2 -2
- package/src/runtime/AgUiThreadController.disconnect.test.ts +198 -3
- package/src/runtime/AgUiThreadController.replay.test.ts +57 -0
- package/src/runtime/AgUiThreadController.snapshot-head-history.test.ts +34 -2
- package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +510 -0
- package/src/runtime/AgUiThreadController.ts +179 -28
- package/src/runtime/adapter/run-aggregator.routing.test.ts +359 -0
- package/src/runtime/adapter/run-aggregator.ts +539 -258
|
@@ -234,7 +234,7 @@ describe("AgUiThreadController HTTP stream closure", () => {
|
|
|
234
234
|
});
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
-
it("settles a nested assistant when a snapshot
|
|
237
|
+
it("settles a cut-off answer and its nested assistant when a snapshot advanced past them", async () => {
|
|
238
238
|
const core = createController([
|
|
239
239
|
start,
|
|
240
240
|
...text,
|
|
@@ -294,14 +294,68 @@ describe("AgUiThreadController HTTP stream closure", () => {
|
|
|
294
294
|
await core.append(userMessage);
|
|
295
295
|
|
|
296
296
|
const answer = core.getMessages().find(({ id }) => id === "answer");
|
|
297
|
-
expect(answer?.status
|
|
297
|
+
expect(answer?.status).toMatchObject({
|
|
298
|
+
type: "incomplete",
|
|
299
|
+
reason: "error",
|
|
300
|
+
});
|
|
298
301
|
expect(answer?.content[1]).toMatchObject({
|
|
299
302
|
type: "tool-call",
|
|
300
303
|
toolCallId: "task",
|
|
301
|
-
messages: [
|
|
304
|
+
messages: [
|
|
305
|
+
{ id: "sub", status: { type: "incomplete", reason: "error" } },
|
|
306
|
+
],
|
|
302
307
|
});
|
|
308
|
+
expect(
|
|
309
|
+
core.getMessages().find(({ id }) => id === "next")?.status?.type,
|
|
310
|
+
).toBe("complete");
|
|
303
311
|
});
|
|
304
312
|
|
|
313
|
+
it.each([
|
|
314
|
+
{
|
|
315
|
+
name: "mid-text after the move",
|
|
316
|
+
events: [
|
|
317
|
+
{ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: " more" },
|
|
318
|
+
],
|
|
319
|
+
status: { type: "incomplete", reason: "error" },
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "right after the move",
|
|
323
|
+
events: [],
|
|
324
|
+
status: { type: "incomplete", reason: "error" },
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: "after the frame closed",
|
|
328
|
+
before: [{ type: "TEXT_MESSAGE_END", messageId: "answer" }],
|
|
329
|
+
events: [],
|
|
330
|
+
status: { type: "complete" },
|
|
331
|
+
},
|
|
332
|
+
])(
|
|
333
|
+
"settles a moved-off answer at EOF $name",
|
|
334
|
+
async ({ before = [], events, status }) => {
|
|
335
|
+
const core = createController([
|
|
336
|
+
start,
|
|
337
|
+
...text,
|
|
338
|
+
...before,
|
|
339
|
+
{
|
|
340
|
+
type: "MESSAGES_SNAPSHOT",
|
|
341
|
+
messages: [
|
|
342
|
+
{ id: "question", role: "user", content: "hello" },
|
|
343
|
+
{ id: "answer", role: "assistant", content: "partial answer" },
|
|
344
|
+
{ id: "next", role: "assistant", content: "next step" },
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
...events,
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
await core.append(userMessage);
|
|
351
|
+
|
|
352
|
+
expect(core.isRunning()).toBe(false);
|
|
353
|
+
expect(
|
|
354
|
+
core.getMessages().find(({ id }) => id === "answer")?.status,
|
|
355
|
+
).toMatchObject(status);
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
|
|
305
359
|
it("streams a nested assistant that starts after a snapshot", async () => {
|
|
306
360
|
const core = createController([
|
|
307
361
|
start,
|
|
@@ -911,4 +965,145 @@ describe("AgUiThreadController HTTP stream closure", () => {
|
|
|
911
965
|
});
|
|
912
966
|
},
|
|
913
967
|
);
|
|
968
|
+
|
|
969
|
+
it.each(
|
|
970
|
+
[
|
|
971
|
+
{
|
|
972
|
+
ending: "EOF",
|
|
973
|
+
runFinished: false,
|
|
974
|
+
expectedStatus: { type: "incomplete", reason: "error" },
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
ending: "RUN_FINISHED",
|
|
978
|
+
runFinished: true,
|
|
979
|
+
expectedStatus: { type: "requires-action", reason: "tool-calls" },
|
|
980
|
+
},
|
|
981
|
+
].flatMap((row) => [
|
|
982
|
+
{ ...row, shape: "with text", otherContent: "Let me check." },
|
|
983
|
+
{ ...row, shape: "with no text", otherContent: "" },
|
|
984
|
+
]),
|
|
985
|
+
)(
|
|
986
|
+
"keeps a moved-to assistant $shape running when a later snapshot adds a tool call through $ending",
|
|
987
|
+
async ({ runFinished, expectedStatus, otherContent }) => {
|
|
988
|
+
let releaseStream!: () => void;
|
|
989
|
+
const streamCanClose = new Promise<void>((resolve) => {
|
|
990
|
+
releaseStream = resolve;
|
|
991
|
+
});
|
|
992
|
+
const answer = { id: "answer", role: "assistant", content: "a1" };
|
|
993
|
+
const other = (withCall: boolean) => ({
|
|
994
|
+
id: "other",
|
|
995
|
+
role: "assistant",
|
|
996
|
+
content: otherContent,
|
|
997
|
+
...(withCall && {
|
|
998
|
+
toolCalls: [
|
|
999
|
+
{
|
|
1000
|
+
id: "ocall",
|
|
1001
|
+
type: "function",
|
|
1002
|
+
function: { name: "lookup", arguments: "{}" },
|
|
1003
|
+
},
|
|
1004
|
+
],
|
|
1005
|
+
}),
|
|
1006
|
+
});
|
|
1007
|
+
const agent = new HttpAgent({
|
|
1008
|
+
url: "https://example.invalid",
|
|
1009
|
+
fetch: async (_url: unknown, init: RequestInit) => {
|
|
1010
|
+
const messages = JSON.parse(init.body as string).messages ?? [];
|
|
1011
|
+
const events = [
|
|
1012
|
+
start,
|
|
1013
|
+
{
|
|
1014
|
+
type: "TEXT_MESSAGE_START",
|
|
1015
|
+
messageId: "answer",
|
|
1016
|
+
role: "assistant",
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
1020
|
+
messageId: "answer",
|
|
1021
|
+
delta: "a1",
|
|
1022
|
+
},
|
|
1023
|
+
{ type: "TEXT_MESSAGE_END", messageId: "answer" },
|
|
1024
|
+
{
|
|
1025
|
+
type: "MESSAGES_SNAPSHOT",
|
|
1026
|
+
messages: [...messages, answer, other(false)],
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
type: "MESSAGES_SNAPSHOT",
|
|
1030
|
+
messages: [...messages, answer, other(true)],
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
type: "STATE_SNAPSHOT",
|
|
1034
|
+
snapshot: { marker: "snapshots-imported" },
|
|
1035
|
+
},
|
|
1036
|
+
];
|
|
1037
|
+
return new Response(
|
|
1038
|
+
new ReadableStream<Uint8Array>({
|
|
1039
|
+
async start(controller) {
|
|
1040
|
+
controller.enqueue(
|
|
1041
|
+
new TextEncoder().encode(
|
|
1042
|
+
events
|
|
1043
|
+
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
|
|
1044
|
+
.join(""),
|
|
1045
|
+
),
|
|
1046
|
+
);
|
|
1047
|
+
await streamCanClose;
|
|
1048
|
+
if (runFinished) {
|
|
1049
|
+
controller.enqueue(
|
|
1050
|
+
new TextEncoder().encode(
|
|
1051
|
+
`data: ${JSON.stringify({
|
|
1052
|
+
type: "RUN_FINISHED",
|
|
1053
|
+
threadId: "thread",
|
|
1054
|
+
runId: "run",
|
|
1055
|
+
})}\n\n`,
|
|
1056
|
+
),
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
controller.close();
|
|
1060
|
+
},
|
|
1061
|
+
}),
|
|
1062
|
+
{ headers: { "Content-Type": "text/event-stream" } },
|
|
1063
|
+
);
|
|
1064
|
+
},
|
|
1065
|
+
});
|
|
1066
|
+
const core = new AgUiThreadController({
|
|
1067
|
+
agent,
|
|
1068
|
+
logger: makeLogger(),
|
|
1069
|
+
showThinking: false,
|
|
1070
|
+
});
|
|
1071
|
+
const snapshotsImported = new Promise<void>((resolve) => {
|
|
1072
|
+
const unsubscribe = core.subscribe(() => {
|
|
1073
|
+
if (
|
|
1074
|
+
JSON.stringify(core.getState()) ===
|
|
1075
|
+
JSON.stringify({ marker: "snapshots-imported" })
|
|
1076
|
+
) {
|
|
1077
|
+
unsubscribe();
|
|
1078
|
+
resolve();
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
const run = core.append(userMessage);
|
|
1084
|
+
await snapshotsImported;
|
|
1085
|
+
const otherDuringRun = core
|
|
1086
|
+
.getMessages()
|
|
1087
|
+
.find(({ id }) => id === "other");
|
|
1088
|
+
const statusDuringRun = {
|
|
1089
|
+
isRunning: core.isRunning(),
|
|
1090
|
+
status: otherDuringRun?.status,
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
releaseStream();
|
|
1094
|
+
await run;
|
|
1095
|
+
|
|
1096
|
+
expect(statusDuringRun).toMatchObject({
|
|
1097
|
+
isRunning: true,
|
|
1098
|
+
status: { type: "running" },
|
|
1099
|
+
});
|
|
1100
|
+
expect(core.isRunning()).toBe(false);
|
|
1101
|
+
expect(
|
|
1102
|
+
core.getMessages().find(({ id }) => id === "other")?.status,
|
|
1103
|
+
).toMatchObject(expectedStatus);
|
|
1104
|
+
expect(core.getPendingToolCalls()).toEqual(
|
|
1105
|
+
runFinished ? { messageId: "other", toolCallIds: ["ocall"] } : null,
|
|
1106
|
+
);
|
|
1107
|
+
},
|
|
1108
|
+
);
|
|
914
1109
|
});
|
|
@@ -3371,6 +3371,63 @@ describe("AgUiThreadController selected-turn replay", () => {
|
|
|
3371
3371
|
expect(attempts).toBe(1);
|
|
3372
3372
|
});
|
|
3373
3373
|
|
|
3374
|
+
it("leaves an off-branch answer alone when the first message collides with its ID", async () => {
|
|
3375
|
+
const core = await loaded(
|
|
3376
|
+
[
|
|
3377
|
+
user("u"),
|
|
3378
|
+
assistant("off-branch", [{ type: "text", text: "old off branch" }]),
|
|
3379
|
+
assistant("selected", [{ type: "text", text: "selected branch" }]),
|
|
3380
|
+
],
|
|
3381
|
+
async (input, subscriber) => {
|
|
3382
|
+
text(subscriber, "off-branch", "answer A");
|
|
3383
|
+
streamToolCall(subscriber, "t", "{}");
|
|
3384
|
+
text(subscriber, "fresh-b", "answer B");
|
|
3385
|
+
emit(subscriber, {
|
|
3386
|
+
type: "TOOL_CALL_RESULT",
|
|
3387
|
+
toolCallId: "t",
|
|
3388
|
+
messageId: "result-t",
|
|
3389
|
+
content: "server result",
|
|
3390
|
+
});
|
|
3391
|
+
emit(subscriber, {
|
|
3392
|
+
type: "TEXT_MESSAGE_END",
|
|
3393
|
+
messageId: "off-branch",
|
|
3394
|
+
});
|
|
3395
|
+
emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
|
|
3396
|
+
},
|
|
3397
|
+
[null, "u", "u"],
|
|
3398
|
+
);
|
|
3399
|
+
|
|
3400
|
+
await core.append({
|
|
3401
|
+
role: "user",
|
|
3402
|
+
content: [{ type: "text", text: "new prompt" }],
|
|
3403
|
+
attachments: [],
|
|
3404
|
+
createdAt: new Date(1),
|
|
3405
|
+
metadata: { custom: {} },
|
|
3406
|
+
parentId: core.getMessageRepository().headId ?? null,
|
|
3407
|
+
sourceId: null,
|
|
3408
|
+
runConfig: {},
|
|
3409
|
+
startRun: true,
|
|
3410
|
+
});
|
|
3411
|
+
|
|
3412
|
+
expect(
|
|
3413
|
+
core
|
|
3414
|
+
.getMessageRepository()
|
|
3415
|
+
.messages.find(({ message }) => message.id === "off-branch")?.message,
|
|
3416
|
+
).toMatchObject({
|
|
3417
|
+
content: [{ type: "text", text: "old off branch" }],
|
|
3418
|
+
status: { type: "complete" },
|
|
3419
|
+
});
|
|
3420
|
+
expect(
|
|
3421
|
+
core
|
|
3422
|
+
.getMessageRepository()
|
|
3423
|
+
.messages.find(({ message }) => message.id === "fresh-b")?.message,
|
|
3424
|
+
).toMatchObject({
|
|
3425
|
+
content: expect.arrayContaining([
|
|
3426
|
+
expect.objectContaining({ toolCallId: "t", result: "server result" }),
|
|
3427
|
+
]),
|
|
3428
|
+
});
|
|
3429
|
+
});
|
|
3430
|
+
|
|
3374
3431
|
it.each(["frontend", "server"] as const)(
|
|
3375
3432
|
"continues a running replay only for a %s result on its retained call",
|
|
3376
3433
|
async (source) => {
|
|
@@ -20,7 +20,7 @@ const userMessage = {
|
|
|
20
20
|
startRun: true,
|
|
21
21
|
} as unknown as AppendMessage;
|
|
22
22
|
|
|
23
|
-
type Row = { id: string; status: string | undefined };
|
|
23
|
+
type Row = { id: string; status: string | undefined; text: string };
|
|
24
24
|
|
|
25
25
|
// Follows the ThreadHistoryAdapter contract: both writes upsert by message
|
|
26
26
|
// id, and only append moves the stored head.
|
|
@@ -36,7 +36,13 @@ function createHistory(writes: string[]) {
|
|
|
36
36
|
new Map<string, Row>(
|
|
37
37
|
[...items.values()].map((item) => [
|
|
38
38
|
item.message.id,
|
|
39
|
-
{
|
|
39
|
+
{
|
|
40
|
+
id: item.message.id,
|
|
41
|
+
status: item.message.status?.type,
|
|
42
|
+
text: item.message.content
|
|
43
|
+
.flatMap((part: any) => (part.type === "text" ? [part.text] : []))
|
|
44
|
+
.join(""),
|
|
45
|
+
},
|
|
40
46
|
]),
|
|
41
47
|
),
|
|
42
48
|
load: async () => ({ headId, messages: [...items.values()] }),
|
|
@@ -202,6 +208,21 @@ describe("ag-ui: a snapshot's new head and the history adapter", () => {
|
|
|
202
208
|
expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
|
|
203
209
|
});
|
|
204
210
|
|
|
211
|
+
it("stores the new head when the moved-off answer settles after the move", async () => {
|
|
212
|
+
const { rows, writes, shown, reloaded } = await run((req) => [
|
|
213
|
+
start,
|
|
214
|
+
[moveSnapshot(req)],
|
|
215
|
+
[{ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" }],
|
|
216
|
+
[end, { type: "RUN_FINISHED", threadId: "t", runId: "r" }],
|
|
217
|
+
]);
|
|
218
|
+
expect(shown.slice(1)).toEqual(["answer:complete", "other:complete"]);
|
|
219
|
+
expect(rows.get("answer"), writes.join(", ")).toMatchObject({
|
|
220
|
+
status: "complete",
|
|
221
|
+
text: "a1a2",
|
|
222
|
+
});
|
|
223
|
+
expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
|
|
224
|
+
});
|
|
225
|
+
|
|
205
226
|
it("control: a second snapshot after the head move stores the new head", async () => {
|
|
206
227
|
const { rows, writes, shown, reloaded } = await run((req) => [
|
|
207
228
|
start,
|
|
@@ -215,6 +236,17 @@ describe("ag-ui: a snapshot's new head and the history adapter", () => {
|
|
|
215
236
|
expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
|
|
216
237
|
});
|
|
217
238
|
|
|
239
|
+
it("stores the new head when the stream ends after the moved-off answer's frame closes", async () => {
|
|
240
|
+
const { rows, writes, shown, reloaded } = await run((req) => [
|
|
241
|
+
start,
|
|
242
|
+
[moveSnapshot(req)],
|
|
243
|
+
[end],
|
|
244
|
+
]);
|
|
245
|
+
expect(shown.slice(1)).toEqual(["answer:complete", "other:incomplete"]);
|
|
246
|
+
expect(rows.get("other")?.status, writes.join(", ")).toBe("incomplete");
|
|
247
|
+
expect(reloaded.slice(1), writes.join(", ")).toEqual(shown.slice(1));
|
|
248
|
+
});
|
|
249
|
+
|
|
218
250
|
it("control: the streamed answer without a snapshot is stored", async () => {
|
|
219
251
|
const { rows, shown, reloaded } = await run(() => [
|
|
220
252
|
start,
|