@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.
@@ -234,7 +234,7 @@ describe("AgUiThreadController HTTP stream closure", () => {
234
234
  });
235
235
  });
236
236
 
237
- it("settles a nested assistant when a snapshot advances past its step", async () => {
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?.type).toBe("complete");
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: [{ id: "sub", status: { type: "complete" } }],
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
- { id: item.message.id, status: item.message.status?.type },
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,