@assistant-ui/react-ag-ui 0.1.1 → 0.1.3

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.
Files changed (43) hide show
  1. package/README.md +2 -0
  2. package/dist/runtime/AgUiThreadController.d.ts +11 -0
  3. package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadController.js +242 -25
  5. package/dist/runtime/AgUiThreadController.js.map +1 -1
  6. package/dist/runtime/adapter/conversions.d.ts +9 -1
  7. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  8. package/dist/runtime/adapter/conversions.js +184 -9
  9. package/dist/runtime/adapter/conversions.js.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.d.ts +2 -0
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +42 -12
  13. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  14. package/dist/runtime/adapter/subscriber.d.ts +0 -1
  15. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  16. package/dist/runtime/adapter/subscriber.js +1 -8
  17. package/dist/runtime/adapter/subscriber.js.map +1 -1
  18. package/dist/runtime/event-parser.d.ts.map +1 -1
  19. package/dist/runtime/event-parser.js +1 -0
  20. package/dist/runtime/event-parser.js.map +1 -1
  21. package/dist/runtime/types.d.ts +2 -0
  22. package/dist/runtime/types.d.ts.map +1 -1
  23. package/dist/runtime/types.js +2 -1
  24. package/dist/runtime/types.js.map +1 -1
  25. package/package.json +6 -5
  26. package/src/runtime/AgUiThreadController.disconnect.test.ts +914 -0
  27. package/src/runtime/AgUiThreadController.interrupt-history.test.ts +392 -0
  28. package/src/runtime/AgUiThreadController.replay.test.ts +8 -1
  29. package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +346 -0
  30. package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
  31. package/src/runtime/AgUiThreadController.test.ts +173 -1
  32. package/src/runtime/AgUiThreadController.ts +528 -33
  33. package/src/runtime/adapter/conversions.content.test.ts +129 -0
  34. package/src/runtime/adapter/conversions.subagent-messages.test.ts +550 -0
  35. package/src/runtime/adapter/conversions.ts +331 -20
  36. package/src/runtime/adapter/run-aggregator.metadata.test.ts +151 -0
  37. package/src/runtime/adapter/run-aggregator.ts +63 -9
  38. package/src/runtime/adapter/subscriber.test.ts +1 -1
  39. package/src/runtime/adapter/subscriber.ts +1 -6
  40. package/src/runtime/event-parser.ts +3 -0
  41. package/src/runtime/types.ts +5 -0
  42. package/src/useAgUiRuntime.cloud-run-leases.test.tsx +384 -0
  43. package/src/useAgUiRuntime.state.test.tsx +87 -0
@@ -0,0 +1,392 @@
1
+ import { describe, expect, it, vi, type Mock } from "vitest";
2
+ import type { AbstractAgent } from "@ag-ui/client";
3
+ import type {
4
+ AppendMessage,
5
+ ExportedMessageRepositoryItem,
6
+ ThreadAssistantMessage,
7
+ ThreadHistoryAdapter,
8
+ } from "@assistant-ui/core";
9
+ import { AgUiThreadController } from "./AgUiThreadController";
10
+ import { makeLogger } from "./logger";
11
+
12
+ type Subscriber = Record<
13
+ string,
14
+ ((payload: { event: unknown }) => void) | undefined
15
+ >;
16
+
17
+ function emit(subscriber: Subscriber, event: Record<string, unknown>) {
18
+ const name = `on${event.type
19
+ ?.toString()
20
+ .toLowerCase()
21
+ .split("_")
22
+ .map((word) => word[0]?.toUpperCase() + word.slice(1))
23
+ .join("")}Event`;
24
+ subscriber[name]?.({ event });
25
+ }
26
+
27
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
28
+
29
+ const userMessage = {
30
+ parentId: null,
31
+ role: "user",
32
+ content: [{ type: "text", text: "order item 7" }],
33
+ } as unknown as AppendMessage;
34
+
35
+ function storedHistory(heldAppendId?: string) {
36
+ const rows: ExportedMessageRepositoryItem[] = [];
37
+ let release = () => {};
38
+ const held = new Promise<void>((resolve) => {
39
+ release = resolve;
40
+ });
41
+ const write = async (item: ExportedMessageRepositoryItem) => {
42
+ const index = rows.findIndex(
43
+ ({ message }) => message.id === item.message.id,
44
+ );
45
+ if (index < 0) throw new Error(`Missing history row ${item.message.id}`);
46
+ rows[index] = item;
47
+ };
48
+ const history: ThreadHistoryAdapter = {
49
+ load: async () => ({
50
+ headId: rows.at(-1)?.message.id ?? null,
51
+ messages: [...rows],
52
+ }),
53
+ append: async (item) => {
54
+ if (item.message.id === heldAppendId) await held;
55
+ rows.push(item);
56
+ },
57
+ update: write,
58
+ };
59
+ return { history, release, rows };
60
+ }
61
+
62
+ function createController(
63
+ history: ThreadHistoryAdapter,
64
+ resumeWithResult: boolean,
65
+ startsWithResume = false,
66
+ ) {
67
+ const runAgent = vi.fn(
68
+ async (input: { runId: string }, subscriber: Subscriber) => {
69
+ if (runAgent.mock.calls.length === 1 && !startsWithResume) {
70
+ emit(subscriber, {
71
+ type: "TOOL_CALL_START",
72
+ toolCallId: "call-1",
73
+ toolCallName: "place_order",
74
+ parentMessageId: "assistant-1",
75
+ });
76
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "call-1" });
77
+ emit(subscriber, {
78
+ type: "RUN_FINISHED",
79
+ runId: input.runId,
80
+ outcome: {
81
+ type: "interrupt",
82
+ interrupts: [
83
+ {
84
+ id: "int-1",
85
+ reason: "tool_call",
86
+ toolCallId: "call-1",
87
+ message: "Approve?",
88
+ },
89
+ ],
90
+ },
91
+ });
92
+ return;
93
+ }
94
+ if (resumeWithResult) {
95
+ emit(subscriber, {
96
+ type: "TOOL_CALL_RESULT",
97
+ messageId: "tool-1",
98
+ toolCallId: "call-1",
99
+ role: "tool",
100
+ content: '"order placed"',
101
+ });
102
+ }
103
+ emit(subscriber, {
104
+ type: "TEXT_MESSAGE_START",
105
+ messageId: "assistant-2",
106
+ role: "assistant",
107
+ });
108
+ emit(subscriber, {
109
+ type: "TEXT_MESSAGE_CONTENT",
110
+ messageId: "assistant-2",
111
+ delta: "Done.",
112
+ });
113
+ emit(subscriber, { type: "TEXT_MESSAGE_END", messageId: "assistant-2" });
114
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
115
+ },
116
+ );
117
+ const core = new AgUiThreadController({
118
+ agent: { runAgent, abortRun: vi.fn() } as unknown as AbstractAgent,
119
+ history,
120
+ logger: makeLogger(),
121
+ showThinking: false,
122
+ });
123
+ return { core, runAgent };
124
+ }
125
+
126
+ describe("AgUiThreadController answered interrupt history", () => {
127
+ it("persists a loaded pending tool call after its result completes it", async () => {
128
+ const { history, rows } = storedHistory();
129
+ const pending: ThreadAssistantMessage = {
130
+ id: "assistant-1",
131
+ role: "assistant",
132
+ content: [
133
+ {
134
+ type: "tool-call",
135
+ toolCallId: "call-1",
136
+ toolName: "place_order",
137
+ args: {},
138
+ argsText: "{}",
139
+ },
140
+ ],
141
+ status: { type: "requires-action", reason: "tool-calls" },
142
+ createdAt: new Date(0),
143
+ metadata: {
144
+ custom: {},
145
+ unstable_state: null,
146
+ unstable_annotations: [],
147
+ unstable_data: [],
148
+ steps: [],
149
+ },
150
+ };
151
+ await history.append({ parentId: null, message: pending });
152
+
153
+ const agent = {
154
+ runAgent: vi.fn(
155
+ async (input: { runId: string }, subscriber: Subscriber) => {
156
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
157
+ },
158
+ ),
159
+ abortRun: vi.fn(),
160
+ } as unknown as AbstractAgent;
161
+ const core = new AgUiThreadController({
162
+ agent,
163
+ history,
164
+ logger: makeLogger(),
165
+ showThinking: false,
166
+ });
167
+ await core.__internal_load();
168
+ core.addToolResult({
169
+ messageId: "assistant-1",
170
+ toolCallId: "call-1",
171
+ toolName: "place_order",
172
+ result: "order placed",
173
+ isError: false,
174
+ });
175
+ await flush();
176
+
177
+ expect(core.getMessages()[0]).toMatchObject({
178
+ status: { type: "complete" },
179
+ content: [{ toolCallId: "call-1", result: "order placed" }],
180
+ });
181
+
182
+ const reloaded = new AgUiThreadController({
183
+ agent,
184
+ history,
185
+ logger: makeLogger(),
186
+ showThinking: false,
187
+ });
188
+ await reloaded.__internal_load();
189
+
190
+ const storedAssistant = rows.filter(
191
+ ({ message }) => message.id === "assistant-1",
192
+ );
193
+ expect(storedAssistant).toHaveLength(1);
194
+ expect(storedAssistant[0]?.message).toMatchObject({
195
+ status: { type: "complete" },
196
+ content: [{ toolCallId: "call-1", result: "order placed" }],
197
+ });
198
+ expect(reloaded.getMessages()[0]).toMatchObject({
199
+ status: { type: "complete" },
200
+ content: [{ toolCallId: "call-1", result: "order placed" }],
201
+ });
202
+ });
203
+
204
+ it("does not write an externally applied tool call when its result completes it", async () => {
205
+ const { history, rows } = storedHistory();
206
+ const update = vi.spyOn(history, "update" as never) as unknown as Mock<
207
+ NonNullable<ThreadHistoryAdapter["update"]>
208
+ >;
209
+ const core = new AgUiThreadController({
210
+ agent: {
211
+ runAgent: vi.fn(),
212
+ abortRun: vi.fn(),
213
+ } as unknown as AbstractAgent,
214
+ history,
215
+ logger: makeLogger(),
216
+ showThinking: false,
217
+ });
218
+ core.applyExternalMessages([
219
+ {
220
+ id: "assistant-1",
221
+ role: "assistant",
222
+ content: [
223
+ {
224
+ type: "tool-call",
225
+ toolCallId: "call-1",
226
+ toolName: "place_order",
227
+ args: {},
228
+ argsText: "{}",
229
+ },
230
+ ],
231
+ status: { type: "requires-action", reason: "tool-calls" },
232
+ createdAt: new Date(0),
233
+ metadata: {
234
+ custom: {},
235
+ unstable_state: null,
236
+ unstable_annotations: [],
237
+ unstable_data: [],
238
+ steps: [],
239
+ },
240
+ },
241
+ ]);
242
+ core.addToolResult({
243
+ messageId: "assistant-1",
244
+ toolCallId: "call-1",
245
+ toolName: "place_order",
246
+ result: "order placed",
247
+ isError: false,
248
+ });
249
+ await flush();
250
+
251
+ expect(core.getMessages()[0]).toMatchObject({
252
+ status: { type: "complete" },
253
+ });
254
+ const written = [...rows, ...update.mock.calls.map(([item]) => item)];
255
+ expect(written.map(({ message }) => message.id)).not.toContain(
256
+ "assistant-1",
257
+ );
258
+ });
259
+
260
+ it("does not write an externally applied message that a later run's result reaches", async () => {
261
+ const { history, rows } = storedHistory();
262
+ const update = vi.spyOn(history, "update" as never) as unknown as Mock<
263
+ NonNullable<ThreadHistoryAdapter["update"]>
264
+ >;
265
+ const core = new AgUiThreadController({
266
+ agent: {
267
+ runAgent: vi.fn(
268
+ async (input: { runId: string }, subscriber: Subscriber) => {
269
+ emit(subscriber, {
270
+ type: "TOOL_CALL_RESULT",
271
+ messageId: "tool-1",
272
+ toolCallId: "call-1",
273
+ content: "order placed",
274
+ });
275
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
276
+ },
277
+ ),
278
+ abortRun: vi.fn(),
279
+ } as unknown as AbstractAgent,
280
+ history,
281
+ logger: makeLogger(),
282
+ showThinking: false,
283
+ });
284
+ core.applyExternalMessages([
285
+ {
286
+ id: "assistant-1",
287
+ role: "assistant",
288
+ content: [
289
+ {
290
+ type: "tool-call",
291
+ toolCallId: "call-1",
292
+ toolName: "place_order",
293
+ args: {},
294
+ argsText: "{}",
295
+ },
296
+ ],
297
+ status: { type: "complete", reason: "unknown" },
298
+ createdAt: new Date(0),
299
+ metadata: {
300
+ custom: {},
301
+ unstable_state: null,
302
+ unstable_annotations: [],
303
+ unstable_data: [],
304
+ steps: [],
305
+ },
306
+ },
307
+ ]);
308
+ await core.append({ ...userMessage, parentId: "assistant-1" });
309
+ await flush();
310
+
311
+ expect(
312
+ core.getMessages().find(({ id }) => id === "assistant-1")?.content,
313
+ ).toMatchObject([{ toolCallId: "call-1", result: "order placed" }]);
314
+ const written = [...rows, ...update.mock.calls.map(([item]) => item)];
315
+ expect(written.map(({ message }) => message.id)).not.toContain(
316
+ "assistant-1",
317
+ );
318
+ });
319
+
320
+ it("updates one stored interrupt when it is answered after a reload", async () => {
321
+ const { history, rows } = storedHistory();
322
+ const first = createController(history, false).core;
323
+ await first.append(userMessage);
324
+ await flush();
325
+
326
+ const second = createController(history, true, true).core;
327
+ await second.__internal_load();
328
+ await second.submitInterruptResponses([
329
+ { interruptId: "int-1", status: "resolved" },
330
+ ]);
331
+ await flush();
332
+
333
+ const third = createController(history, false).core;
334
+ await third.__internal_load();
335
+
336
+ expect(
337
+ rows.filter(({ message }) => message.id === "assistant-1"),
338
+ ).toHaveLength(1);
339
+ expect(
340
+ third.getMessages().find(({ id }) => id === "assistant-1"),
341
+ ).toMatchObject({
342
+ status: { type: "complete" },
343
+ content: [{ toolCallId: "call-1", result: "order placed" }],
344
+ });
345
+ });
346
+
347
+ it.each([
348
+ { status: "resolved" as const, resumeWithResult: true, append: "settled" },
349
+ {
350
+ status: "cancelled" as const,
351
+ resumeWithResult: false,
352
+ append: "settled",
353
+ },
354
+ { status: "resolved" as const, resumeWithResult: true, append: "pending" },
355
+ {
356
+ status: "cancelled" as const,
357
+ resumeWithResult: false,
358
+ append: "pending",
359
+ },
360
+ ])(
361
+ "keeps a $status interrupt answered after a reload when the first append is $append",
362
+ async ({ status, resumeWithResult, append }) => {
363
+ const { history, release } = storedHistory(
364
+ append === "pending" ? "assistant-1" : undefined,
365
+ );
366
+ const { core, runAgent } = createController(history, resumeWithResult);
367
+ await core.append(userMessage);
368
+ await flush();
369
+ await core.submitInterruptResponses([{ interruptId: "int-1", status }]);
370
+ release();
371
+ await flush();
372
+
373
+ const reloaded = createController(history, false).core;
374
+ await reloaded.__internal_load();
375
+
376
+ expect(runAgent).toHaveBeenCalledTimes(2);
377
+ expect(reloaded.getPendingInterrupts()).toBeNull();
378
+ expect(
379
+ reloaded.getMessages().find(({ id }) => id === "assistant-1"),
380
+ ).toMatchObject({
381
+ status: { type: "complete" },
382
+ content: [
383
+ {
384
+ type: "tool-call",
385
+ toolCallId: "call-1",
386
+ ...(resumeWithResult ? { result: "order placed" } : {}),
387
+ },
388
+ ],
389
+ });
390
+ },
391
+ );
392
+ });
@@ -3286,6 +3286,7 @@ describe("AgUiThreadController selected-turn replay", () => {
3286
3286
  toolCallId: "x",
3287
3287
  messageId: "result-x",
3288
3288
  content: "server result",
3289
+ metadata: { trace: "cross-run", retry: 0 },
3289
3290
  });
3290
3291
  emit(subscriber, {
3291
3292
  type: "RUN_FINISHED",
@@ -3296,7 +3297,13 @@ describe("AgUiThreadController selected-turn replay", () => {
3296
3297
 
3297
3298
  await resume(core, "u");
3298
3299
  expect(contents(core, "a")).toContainEqual(
3299
- expect.objectContaining({ toolCallId: "x", result: "server result" }),
3300
+ expect.objectContaining({
3301
+ toolCallId: "x",
3302
+ result: "server result",
3303
+ providerMetadata: {
3304
+ agui: { messageMetadata: { trace: "cross-run", retry: 0 } },
3305
+ },
3306
+ }),
3300
3307
  );
3301
3308
  expect(
3302
3309
  core.getMessages().find((message) => message.id === "a")?.status,