@assistant-ui/react-ag-ui 0.1.0 → 0.1.2

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 (65) hide show
  1. package/README.md +2 -0
  2. package/dist/agUiExtras.js +1 -2
  3. package/dist/hooks.js +1 -2
  4. package/dist/runtime/AgUiThreadController.d.ts +11 -0
  5. package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
  6. package/dist/runtime/AgUiThreadController.js +565 -89
  7. package/dist/runtime/AgUiThreadController.js.map +1 -1
  8. package/dist/runtime/adapter/conversions.d.ts +9 -1
  9. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  10. package/dist/runtime/adapter/conversions.js +194 -12
  11. package/dist/runtime/adapter/conversions.js.map +1 -1
  12. package/dist/runtime/adapter/replay-content.d.ts +9 -0
  13. package/dist/runtime/adapter/replay-content.d.ts.map +1 -0
  14. package/dist/runtime/adapter/replay-content.js +185 -0
  15. package/dist/runtime/adapter/replay-content.js.map +1 -0
  16. package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
  17. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  18. package/dist/runtime/adapter/run-aggregator.js +35 -14
  19. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  20. package/dist/runtime/adapter/subscriber.d.ts +0 -1
  21. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  22. package/dist/runtime/adapter/subscriber.js +2 -10
  23. package/dist/runtime/adapter/subscriber.js.map +1 -1
  24. package/dist/runtime/adapter/tool-approval.d.ts +2 -0
  25. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  26. package/dist/runtime/adapter/tool-approval.js +23 -6
  27. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  28. package/dist/runtime/agUiThreadState.d.ts +1 -1
  29. package/dist/runtime/agUiThreadState.d.ts.map +1 -1
  30. package/dist/runtime/agUiThreadState.js +3 -4
  31. package/dist/runtime/agUiThreadState.js.map +1 -1
  32. package/dist/runtime/event-parser.d.ts.map +1 -1
  33. package/dist/runtime/event-parser.js +2 -2
  34. package/dist/runtime/event-parser.js.map +1 -1
  35. package/dist/runtime/interrupt-internals.js +1 -2
  36. package/dist/runtime/logger.js +1 -2
  37. package/dist/runtime/mcp-tool-result.js +1 -2
  38. package/dist/runtime/types.d.ts +2 -0
  39. package/dist/runtime/types.d.ts.map +1 -1
  40. package/dist/runtime/types.js +3 -3
  41. package/dist/runtime/types.js.map +1 -1
  42. package/dist/useAgUiRuntime.js +1 -2
  43. package/package.json +6 -6
  44. package/src/runtime/AgUiThreadController.deferred-results.test.ts +548 -0
  45. package/src/runtime/AgUiThreadController.disconnect.test.ts +914 -0
  46. package/src/runtime/AgUiThreadController.interrupt-history.test.ts +392 -0
  47. package/src/runtime/AgUiThreadController.replay.test.ts +3939 -0
  48. package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +167 -0
  49. package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
  50. package/src/runtime/AgUiThreadController.test.ts +119 -0
  51. package/src/runtime/AgUiThreadController.ts +1184 -136
  52. package/src/runtime/adapter/conversions.content.test.ts +242 -1
  53. package/src/runtime/adapter/conversions.subagent-messages.test.ts +550 -0
  54. package/src/runtime/adapter/conversions.ts +349 -28
  55. package/src/runtime/adapter/replay-content.test.ts +235 -0
  56. package/src/runtime/adapter/replay-content.ts +306 -0
  57. package/src/runtime/adapter/run-aggregator.metadata.test.ts +151 -0
  58. package/src/runtime/adapter/run-aggregator.ts +52 -9
  59. package/src/runtime/adapter/subscriber.test.ts +1 -1
  60. package/src/runtime/adapter/subscriber.ts +1 -6
  61. package/src/runtime/adapter/tool-approval.ts +41 -3
  62. package/src/runtime/agUiThreadState.test.ts +21 -2
  63. package/src/runtime/agUiThreadState.ts +12 -3
  64. package/src/runtime/event-parser.ts +3 -0
  65. package/src/runtime/types.ts +5 -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
+ });