@assistant-ui/react-ag-ui 0.1.4 → 0.1.6

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.
@@ -0,0 +1,359 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ChatModelRunResult } from "@assistant-ui/core";
3
+ import type { JoinStrategy } from "@assistant-ui/core/react";
4
+ import { RunAggregator, type RetainedMessageState } from "./run-aggregator";
5
+ import type { AgUiEvent } from "../types";
6
+
7
+ const setup = (joinStrategy?: JoinStrategy) => {
8
+ const current: ChatModelRunResult[] = [];
9
+ const retained: {
10
+ update: ChatModelRunResult;
11
+ state: RetainedMessageState;
12
+ }[] = [];
13
+ const aggregator = new RunAggregator({
14
+ showThinking: true,
15
+ joinStrategy,
16
+ logger: { debug: vi.fn(), error: vi.fn() },
17
+ emit: (update) => current.push(update),
18
+ onRetainedMessage: (update, state) => retained.push({ update, state }),
19
+ onTextMessageStart: () => {},
20
+ });
21
+ const send = (...events: AgUiEvent[]) => {
22
+ for (const event of events) aggregator.handle(event);
23
+ };
24
+ send(
25
+ { type: "RUN_STARTED", runId: "run" },
26
+ { type: "TEXT_MESSAGE_START", messageId: "answer" },
27
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a1" },
28
+ {
29
+ type: "TOOL_CALL_START",
30
+ toolCallId: "task",
31
+ toolCallName: "task",
32
+ parentMessageId: "answer",
33
+ },
34
+ {
35
+ type: "SUBAGENT_STARTED",
36
+ subagentRunId: "sub",
37
+ name: "worker",
38
+ parentToolCallId: "task",
39
+ },
40
+ );
41
+ aggregator.rebaseMessage("other", "answer");
42
+ aggregator.announceRetained();
43
+ const lastRetained = () => retained.at(-1);
44
+ const lastCurrent = () => current.at(-1)?.content ?? [];
45
+ const retainedTask = () =>
46
+ lastRetained()?.update.content?.find(
47
+ (part) => part.type === "tool-call" && part.toolCallId === "task",
48
+ );
49
+ return {
50
+ aggregator,
51
+ send,
52
+ current,
53
+ retained,
54
+ lastRetained,
55
+ lastCurrent,
56
+ retainedTask,
57
+ };
58
+ };
59
+
60
+ describe("RunAggregator routing after the current message moves", () => {
61
+ it("announces the moved-off message once with what is still open", () => {
62
+ const { aggregator, retained } = setup();
63
+ aggregator.announceRetained();
64
+
65
+ expect(retained).toHaveLength(1);
66
+ expect(retained[0]?.state).toEqual({
67
+ messageId: "answer",
68
+ streaming: true,
69
+ openToolCallIds: ["task"],
70
+ });
71
+ expect(aggregator.isRetained("answer")).toBe(true);
72
+ expect(aggregator.hasToolCall("task")).toBe(true);
73
+ });
74
+
75
+ it("routes root text naming the moved-off message to it", () => {
76
+ const { send, lastRetained, current } = setup();
77
+ const emitted = current.length;
78
+ send({ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" });
79
+
80
+ expect(lastRetained()?.update.content?.[0]).toEqual({
81
+ type: "text",
82
+ text: "a1a2",
83
+ });
84
+ expect(current).toHaveLength(emitted);
85
+ });
86
+
87
+ it("closes the text frame on TEXT_MESSAGE_END", () => {
88
+ const { send, lastRetained } = setup();
89
+ send({ type: "TEXT_MESSAGE_END", messageId: "answer" });
90
+
91
+ expect(lastRetained()?.state.streaming).toBe(true);
92
+ send(
93
+ { type: "SUBAGENT_FINISHED", subagentRunId: "sub" },
94
+ {
95
+ type: "TOOL_CALL_RESULT",
96
+ messageId: "tool",
97
+ toolCallId: "task",
98
+ content: "done",
99
+ },
100
+ );
101
+ expect(lastRetained()?.state).toEqual({
102
+ messageId: "answer",
103
+ streaming: false,
104
+ openToolCallIds: [],
105
+ });
106
+ });
107
+
108
+ it("routes TOOL_CALL_START by parentMessageId, and to the current message without one", () => {
109
+ const { send, lastRetained, lastCurrent } = setup();
110
+ send(
111
+ {
112
+ type: "TOOL_CALL_START",
113
+ toolCallId: "late",
114
+ toolCallName: "late",
115
+ parentMessageId: "answer",
116
+ },
117
+ { type: "TOOL_CALL_START", toolCallId: "next", toolCallName: "next" },
118
+ );
119
+
120
+ expect(lastRetained()?.state.openToolCallIds).toEqual(["task", "late"]);
121
+ expect(
122
+ lastCurrent().filter((part) => part.type === "tool-call"),
123
+ ).toMatchObject([{ toolCallId: "next" }]);
124
+ });
125
+
126
+ it("routes TOOL_CALL_ARGS, END, and RESULT by the call they name", () => {
127
+ const { send, retainedTask, current } = setup();
128
+ const emitted = current.length;
129
+ send(
130
+ { type: "TOOL_CALL_ARGS", toolCallId: "task", delta: '{"q":1}' },
131
+ { type: "TOOL_CALL_END", toolCallId: "task" },
132
+ {
133
+ type: "TOOL_CALL_RESULT",
134
+ messageId: "tool",
135
+ toolCallId: "task",
136
+ content: "done",
137
+ },
138
+ );
139
+
140
+ expect(retainedTask()).toMatchObject({ args: { q: 1 }, result: "done" });
141
+ expect(current).toHaveLength(emitted);
142
+ });
143
+
144
+ it("routes sub-agent events by run id and nests a new run under its call", () => {
145
+ const { send, retainedTask, current } = setup();
146
+ const emitted = current.length;
147
+ send(
148
+ {
149
+ type: "TEXT_MESSAGE_CONTENT",
150
+ messageId: "s1",
151
+ delta: "working",
152
+ subagentRunId: "sub",
153
+ },
154
+ {
155
+ type: "SUBAGENT_STARTED",
156
+ subagentRunId: "sub2",
157
+ name: "helper",
158
+ parentToolCallId: "task",
159
+ },
160
+ { type: "SUBAGENT_ERROR", subagentRunId: "sub2", message: "boom" },
161
+ { type: "SUBAGENT_FINISHED", subagentRunId: "sub" },
162
+ );
163
+
164
+ const task = retainedTask();
165
+ expect(task?.type === "tool-call" && task.messages).toMatchObject([
166
+ {
167
+ id: "sub",
168
+ status: { type: "complete" },
169
+ content: [{ type: "text", text: "working" }],
170
+ },
171
+ { id: "sub2", status: { type: "incomplete", reason: "error" } },
172
+ ]);
173
+ expect(current).toHaveLength(emitted);
174
+ });
175
+
176
+ it("routes an MCP app snapshot by its tool call", () => {
177
+ const { send, retainedTask } = setup();
178
+ send({
179
+ type: "ACTIVITY_SNAPSHOT",
180
+ messageId: "act",
181
+ activityType: "mcp-apps",
182
+ content: { toolCallId: "task", resourceUri: "ui://app/view" },
183
+ } as AgUiEvent);
184
+
185
+ expect(retainedTask()).toMatchObject({
186
+ mcp: { app: { resourceUri: "ui://app/view" } },
187
+ });
188
+ });
189
+
190
+ it("routes reasoning and activity deltas to the message that holds them", () => {
191
+ const { aggregator, send, lastRetained, current } = setup();
192
+ aggregator.rebaseMessage("answer", "other");
193
+ send(
194
+ { type: "REASONING_MESSAGE_START", messageId: "r" },
195
+ { type: "REASONING_MESSAGE_CONTENT", messageId: "r", delta: "think" },
196
+ {
197
+ type: "ACTIVITY_SNAPSHOT",
198
+ messageId: "act",
199
+ activityType: "progress",
200
+ content: { step: 1 },
201
+ } as AgUiEvent,
202
+ );
203
+ aggregator.rebaseMessage("other", "answer");
204
+ const emitted = current.length;
205
+ send({ type: "REASONING_MESSAGE_CONTENT", messageId: "r", delta: "ing" }, {
206
+ type: "ACTIVITY_DELTA",
207
+ messageId: "act",
208
+ activityType: "progress",
209
+ patch: [{ op: "replace", path: "/step", value: 2 }],
210
+ } as AgUiEvent);
211
+
212
+ expect(current).toHaveLength(emitted);
213
+ expect(lastRetained()?.update.content).toEqual(
214
+ expect.arrayContaining([
215
+ expect.objectContaining({ type: "reasoning", text: "thinking" }),
216
+ expect.objectContaining({ type: "data", data: { step: 2 } }),
217
+ ]),
218
+ );
219
+ });
220
+
221
+ it("replaces an activity snapshot on the message that holds it", () => {
222
+ const { aggregator, send, lastRetained, current } = setup();
223
+ aggregator.rebaseMessage("answer", "other");
224
+ send({
225
+ type: "ACTIVITY_SNAPSHOT",
226
+ messageId: "act",
227
+ activityType: "progress",
228
+ content: { step: 1 },
229
+ } as AgUiEvent);
230
+ aggregator.rebaseMessage("other", "answer");
231
+ const emitted = current.length;
232
+ send({
233
+ type: "ACTIVITY_SNAPSHOT",
234
+ messageId: "act",
235
+ activityType: "progress",
236
+ content: { step: 2 },
237
+ } as AgUiEvent);
238
+
239
+ expect(
240
+ lastRetained()?.update.content?.filter((part) => part.type === "data"),
241
+ ).toEqual([expect.objectContaining({ data: { step: 2 } })]);
242
+ expect(current).toHaveLength(emitted);
243
+ });
244
+
245
+ it("keeps parsing args a snapshot started", () => {
246
+ const { send, retainedTask } = setup();
247
+ send(
248
+ {
249
+ type: "MESSAGES_SNAPSHOT",
250
+ messages: [
251
+ {
252
+ id: "answer",
253
+ role: "assistant",
254
+ content: "a1",
255
+ toolCalls: [
256
+ {
257
+ id: "task",
258
+ type: "function",
259
+ function: { name: "task", arguments: '{"q":' },
260
+ },
261
+ ],
262
+ },
263
+ ],
264
+ },
265
+ { type: "TOOL_CALL_ARGS", toolCallId: "task", delta: '"x"}' },
266
+ );
267
+
268
+ expect(retainedTask()).toMatchObject({
269
+ argsText: '{"q":"x"}',
270
+ args: { q: "x" },
271
+ });
272
+ });
273
+
274
+ it("keeps root reasoning and custom events on the current message", () => {
275
+ const { send, lastCurrent, retained } = setup();
276
+ const announced = retained.length;
277
+ send(
278
+ { type: "REASONING_MESSAGE_START", messageId: "r" },
279
+ { type: "REASONING_MESSAGE_CONTENT", messageId: "r", delta: "hmm" },
280
+ { type: "CUSTOM", name: "note", value: 1 },
281
+ );
282
+
283
+ expect(lastCurrent().map((part) => part.type)).toEqual([
284
+ "reasoning",
285
+ "data",
286
+ ]);
287
+ expect(retained).toHaveLength(announced);
288
+ });
289
+
290
+ it("applies MESSAGES_SNAPSHOT text and args to every message", () => {
291
+ const { send, retainedTask, lastRetained } = setup();
292
+ send(
293
+ {
294
+ type: "MESSAGES_SNAPSHOT",
295
+ messages: [
296
+ {
297
+ id: "answer",
298
+ role: "assistant",
299
+ content: "a1 fixed",
300
+ toolCalls: [
301
+ {
302
+ id: "task",
303
+ type: "function",
304
+ function: { name: "task", arguments: '{"q":2}' },
305
+ },
306
+ ],
307
+ },
308
+ ],
309
+ },
310
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "!" },
311
+ );
312
+
313
+ expect(lastRetained()?.update.content?.[0]).toEqual({
314
+ type: "text",
315
+ text: "a1 fixed!",
316
+ });
317
+ expect(retainedTask()).toMatchObject({ argsText: '{"q":2}' });
318
+ });
319
+
320
+ it("settles running sub-agents on moved-off messages before the current emit", () => {
321
+ const { send, retained, current, retainedTask } = setup();
322
+ send({ type: "RUN_ERROR", message: "boom" });
323
+
324
+ const task = retainedTask();
325
+ expect(task?.type === "tool-call" && task.messages?.[0]?.status).toEqual({
326
+ type: "incomplete",
327
+ reason: "error",
328
+ error: "boom",
329
+ });
330
+ expect(retained.at(-1)?.update.status).toBeUndefined();
331
+ expect(current.at(-1)?.status).toMatchObject({ type: "incomplete" });
332
+ });
333
+
334
+ it("resumes a moved-off message that becomes current again", () => {
335
+ const { aggregator, send, lastCurrent } = setup();
336
+ aggregator.rebaseMessage("answer", "other");
337
+ send({ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" });
338
+
339
+ expect(lastCurrent()[0]).toEqual({ type: "text", text: "a1a2" });
340
+ expect(aggregator.isRetained("other")).toBe(true);
341
+ });
342
+
343
+ it("forgets moved-off messages when a new run starts", () => {
344
+ const { aggregator, send } = setup();
345
+ send({ type: "RUN_STARTED", runId: "next" });
346
+
347
+ expect(aggregator.isRetained("answer")).toBe(false);
348
+ expect(aggregator.hasToolCall("task")).toBe(false);
349
+ });
350
+
351
+ it("control: concat-content routes everything to the current message", () => {
352
+ const { send, lastCurrent, retained } = setup("concat-content");
353
+ const announced = retained.length;
354
+ send({ type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" });
355
+
356
+ expect(retained).toHaveLength(announced);
357
+ expect(lastCurrent()).toContainEqual({ type: "text", text: "a2" });
358
+ });
359
+ });