@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,235 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type {
3
+ ThreadAssistantMessage,
4
+ ToolCallMessagePart,
5
+ } from "@assistant-ui/core";
6
+ import {
7
+ createReplayContentMerger,
8
+ preserveToolResult,
9
+ } from "./replay-content";
10
+
11
+ const call = (
12
+ id: string,
13
+ extra: Partial<ToolCallMessagePart> = {},
14
+ ): ToolCallMessagePart => ({
15
+ type: "tool-call",
16
+ toolCallId: id,
17
+ toolName: "lookup",
18
+ args: {},
19
+ argsText: "{}",
20
+ ...extra,
21
+ });
22
+
23
+ const assistant = (
24
+ id: string,
25
+ content: ThreadAssistantMessage["content"],
26
+ ): ThreadAssistantMessage => ({
27
+ id,
28
+ role: "assistant",
29
+ createdAt: new Date(0),
30
+ status: { type: "requires-action", reason: "tool-calls" },
31
+ content,
32
+ metadata: {
33
+ unstable_state: null,
34
+ unstable_annotations: [],
35
+ unstable_data: [],
36
+ steps: [],
37
+ custom: {},
38
+ },
39
+ });
40
+
41
+ describe("live replay content", () => {
42
+ it("keeps a locally supplied falsy result and its details until a fresh result arrives", () => {
43
+ const prior = call("tool", {
44
+ result: 0,
45
+ artifact: null,
46
+ isError: false,
47
+ modelContent: [],
48
+ });
49
+ expect(preserveToolResult(prior, call("tool"))).toMatchObject({
50
+ result: 0,
51
+ artifact: null,
52
+ isError: false,
53
+ modelContent: [],
54
+ });
55
+ expect(
56
+ preserveToolResult(prior, call("tool", { result: "fresh" })),
57
+ ).toMatchObject({
58
+ result: "fresh",
59
+ });
60
+ });
61
+
62
+ it("updates a flattened child in its nested owner while retaining unreplayed children", () => {
63
+ const mergeReplayContent = createReplayContentMerger();
64
+ const previous = [
65
+ call("root", {
66
+ messages: [
67
+ assistant("sub", [
68
+ call("child", { args: { old: true }, argsText: '{"old":true}' }),
69
+ call("pending"),
70
+ ]),
71
+ ],
72
+ }),
73
+ ];
74
+ const next = [
75
+ call("root", {
76
+ messages: [
77
+ {
78
+ ...assistant("sub", [{ type: "text", text: "fresh output" }]),
79
+ status: { type: "complete", reason: "stop" } as const,
80
+ },
81
+ ],
82
+ }),
83
+ call("child", {
84
+ args: { fresh: true },
85
+ argsText: '{"fresh":true}',
86
+ result: "ok",
87
+ }),
88
+ ];
89
+
90
+ const merged = mergeReplayContent(previous, next);
91
+ expect(merged).toHaveLength(1);
92
+ const root = merged[0] as ToolCallMessagePart;
93
+ const sub = root.messages?.[0] as ThreadAssistantMessage;
94
+ expect(sub.status).toEqual({ type: "complete", reason: "stop" });
95
+ expect(sub.content).toEqual([
96
+ expect.objectContaining({
97
+ toolCallId: "child",
98
+ args: { fresh: true },
99
+ result: "ok",
100
+ }),
101
+ expect.objectContaining({ toolCallId: "pending" }),
102
+ { type: "text", text: "fresh output" },
103
+ ]);
104
+ });
105
+
106
+ it("uses incoming part and nested message order while retaining an unreplayed child", () => {
107
+ const mergeReplayContent = createReplayContentMerger();
108
+ const previous = [
109
+ call("root", {
110
+ messages: [
111
+ assistant("first", [call("pending")]),
112
+ assistant("second", [{ type: "text", text: "old" }]),
113
+ ],
114
+ }),
115
+ ];
116
+ const next = [
117
+ { type: "text" as const, text: "fresh introduction" },
118
+ call("root", {
119
+ messages: [
120
+ assistant("second", [{ type: "text", text: "fresh second" }]),
121
+ assistant("first", [{ type: "text", text: "fresh first" }]),
122
+ ],
123
+ }),
124
+ ];
125
+
126
+ const merged = mergeReplayContent(previous, next);
127
+ expect(merged[0]).toEqual({ type: "text", text: "fresh introduction" });
128
+ const root = merged[1] as ToolCallMessagePart;
129
+ expect(root.messages?.map((message) => message.id)).toEqual([
130
+ "second",
131
+ "first",
132
+ ]);
133
+ expect((root.messages?.[1] as ThreadAssistantMessage).content).toEqual([
134
+ expect.objectContaining({ toolCallId: "pending" }),
135
+ { type: "text", text: "fresh first" },
136
+ ]);
137
+ });
138
+ it("transfers a completed flat call into its incoming nested owner without retaining a duplicate", () => {
139
+ const mergeReplayContent = createReplayContentMerger();
140
+ const interactions = {
141
+ entries: [
142
+ { type: "action" as const, occurredAt: 1, payload: { approved: true } },
143
+ ],
144
+ };
145
+ const merged = mergeReplayContent(
146
+ [call("child", { result: 0, unstable_interactions: interactions })],
147
+ [
148
+ call("parent", {
149
+ messages: [
150
+ assistant("sub", [
151
+ call("child", {
152
+ args: { fresh: true },
153
+ argsText: '{"fresh":true}',
154
+ }),
155
+ ]),
156
+ ],
157
+ }),
158
+ ],
159
+ );
160
+ expect(merged).toHaveLength(1);
161
+ const parent = merged[0] as ToolCallMessagePart;
162
+ expect(parent.toolCallId).toBe("parent");
163
+ expect((parent.messages?.[0] as ThreadAssistantMessage).content).toEqual([
164
+ expect.objectContaining({
165
+ toolCallId: "child",
166
+ result: 0,
167
+ args: { fresh: true },
168
+ unstable_interactions: interactions,
169
+ }),
170
+ ]);
171
+ });
172
+
173
+ it("removes a replay-owned tool after an unrelated content update", () => {
174
+ const merge = createReplayContentMerger();
175
+ const historical = call("historical", { result: "saved" });
176
+ const surface = call("a2ui:surface");
177
+ const first = merge([historical], [surface]);
178
+ expect(
179
+ first.map((part) => part.type === "tool-call" && part.toolCallId),
180
+ ).toEqual(["historical", "a2ui:surface"]);
181
+
182
+ const second = merge(first, [{ type: "text", text: "continued" }]);
183
+ expect(second).toEqual([historical, { type: "text", text: "continued" }]);
184
+ });
185
+
186
+ it("retains a local tool result until the replay explicitly removes its call", () => {
187
+ const merge = createReplayContentMerger();
188
+ const updated = merge([call("child", { result: 0 })], [call("child")]);
189
+ expect(updated).toEqual([expect.objectContaining({ result: 0 })]);
190
+ expect(merge(updated, [])).toEqual([]);
191
+ });
192
+
193
+ it("keeps a removed parent only while its incoming flattened child needs it", () => {
194
+ const merge = createReplayContentMerger();
195
+ const parent = call("parent", {
196
+ messages: [assistant("sub", [call("child")])],
197
+ });
198
+ const first = merge([], [parent]);
199
+ const second = merge(first, [call("child", { result: "done" })]);
200
+ expect(second).toHaveLength(1);
201
+ expect((second[0] as ToolCallMessagePart).messages?.[0]).toMatchObject({
202
+ content: [
203
+ expect.objectContaining({ toolCallId: "child", result: "done" }),
204
+ ],
205
+ });
206
+ expect(merge(second, [])).toEqual([]);
207
+ });
208
+
209
+ it("scopes matching nested message identities to their tool container", () => {
210
+ const merge = createReplayContentMerger();
211
+ const first = merge(
212
+ [],
213
+ [
214
+ call("left", {
215
+ messages: [assistant("same", [{ type: "text", text: "left" }])],
216
+ }),
217
+ ],
218
+ );
219
+ const second = merge(first, [
220
+ call("right", {
221
+ messages: [assistant("same", [{ type: "text", text: "right" }])],
222
+ }),
223
+ ]);
224
+ expect(second).toEqual([
225
+ expect.objectContaining({
226
+ toolCallId: "right",
227
+ messages: [
228
+ expect.objectContaining({
229
+ content: [{ type: "text", text: "right" }],
230
+ }),
231
+ ],
232
+ }),
233
+ ]);
234
+ });
235
+ });
@@ -0,0 +1,306 @@
1
+ import type {
2
+ ThreadAssistantMessage,
3
+ ThreadMessage,
4
+ ToolCallMessagePart,
5
+ } from "@assistant-ui/core";
6
+ import {
7
+ iterateToolCallParts,
8
+ mapToolCallPartsDeep,
9
+ } from "@assistant-ui/core/internal";
10
+
11
+ import { preserveToolApprovalDecision } from "./tool-approval";
12
+
13
+ type Content = ThreadAssistantMessage["content"];
14
+ type Part = Content[number];
15
+
16
+ export function indexReplayToolOwners(
17
+ owners: readonly ThreadAssistantMessage[],
18
+ ): ReadonlyMap<string, string> {
19
+ const ownerByToolCallId = new Map<string, string>();
20
+ for (const owner of owners) {
21
+ for (const part of iterateToolCallParts(owner.content)) {
22
+ if (!ownerByToolCallId.has(part.toolCallId))
23
+ ownerByToolCallId.set(part.toolCallId, owner.id);
24
+ }
25
+ }
26
+ return ownerByToolCallId;
27
+ }
28
+
29
+ export function groupReplayToolContent(
30
+ owners: readonly ThreadAssistantMessage[],
31
+ content: Content,
32
+ fallbackMessageId: string,
33
+ ): Map<string, Part[]> {
34
+ const ownerByToolCallId = indexReplayToolOwners(owners);
35
+ const grouped = new Map<string, Part[]>([[fallbackMessageId, []]]);
36
+ for (const part of content) {
37
+ const ownerId =
38
+ part.type === "tool-call"
39
+ ? (ownerByToolCallId.get(part.toolCallId) ?? fallbackMessageId)
40
+ : fallbackMessageId;
41
+ const parts = grouped.get(ownerId) ?? [];
42
+ parts.push(part);
43
+ grouped.set(ownerId, parts);
44
+ }
45
+ return grouped;
46
+ }
47
+
48
+ export function preserveToolResult(
49
+ previous: ToolCallMessagePart,
50
+ next: ToolCallMessagePart,
51
+ ): ToolCallMessagePart {
52
+ if (previous.result === undefined || next.result !== undefined) return next;
53
+ return {
54
+ ...next,
55
+ result: previous.result,
56
+ ...(previous.artifact !== undefined ? { artifact: previous.artifact } : {}),
57
+ ...(previous.isError !== undefined ? { isError: previous.isError } : {}),
58
+ ...(previous.modelContent !== undefined
59
+ ? { modelContent: previous.modelContent }
60
+ : {}),
61
+ };
62
+ }
63
+
64
+ function mergeOrdered<T>(
65
+ previous: readonly (readonly [T, string])[],
66
+ next: readonly (readonly [T, string])[],
67
+ merge: (old: T, fresh: T) => T,
68
+ ): T[] {
69
+ const old = new Map(previous.map(([value, key]) => [key, value] as const));
70
+ const indexes = new Map(next.map(([, key], index) => [key, index] as const));
71
+ const insertions: T[][] = Array.from({ length: next.length + 1 }, () => []);
72
+ let predecessor = -1;
73
+ for (const [value, key] of previous) {
74
+ const index = indexes.get(key);
75
+ if (index === undefined) insertions[predecessor + 1]!.push(value);
76
+ else predecessor = index;
77
+ }
78
+ const result: T[] = [];
79
+ for (const [index, [value, key]] of next.entries()) {
80
+ const prior = old.get(key);
81
+ result.push(
82
+ ...insertions[index]!,
83
+ prior === undefined ? value : merge(prior, value),
84
+ );
85
+ }
86
+ result.push(...insertions[next.length]!);
87
+ return result;
88
+ }
89
+
90
+ function mergeMessages(
91
+ previous: readonly ThreadMessage[],
92
+ next: readonly ThreadMessage[],
93
+ ): readonly ThreadMessage[] {
94
+ return mergeOrdered(
95
+ previous.map((message) => [message, message.id] as const),
96
+ next.map((message) => [message, message.id] as const),
97
+ (old, fresh) =>
98
+ old.role === "assistant" && fresh.role === "assistant"
99
+ ? { ...fresh, content: mergeReplayContent(old.content, fresh.content) }
100
+ : fresh,
101
+ );
102
+ }
103
+
104
+ function mergeTool(
105
+ previous: ToolCallMessagePart,
106
+ next: ToolCallMessagePart,
107
+ ): ToolCallMessagePart {
108
+ const result = preserveToolResult(previous, next);
109
+ const approval = preserveToolApprovalDecision(previous, next);
110
+ return {
111
+ ...result,
112
+ ...(approval ? { approval } : {}),
113
+ ...(result.unstable_interactions === undefined &&
114
+ previous.unstable_interactions !== undefined
115
+ ? { unstable_interactions: previous.unstable_interactions }
116
+ : {}),
117
+ ...(previous.messages !== undefined || next.messages !== undefined
118
+ ? {
119
+ messages: mergeMessages(previous.messages ?? [], next.messages ?? []),
120
+ }
121
+ : {}),
122
+ };
123
+ }
124
+
125
+ function partKey(part: Part): string {
126
+ if (part.type === "tool-call") return `tool:${part.toolCallId}`;
127
+ if (part.type === "text" || part.type === "reasoning") return part.type;
128
+ if ("id" in part && typeof part.id === "string" && part.id)
129
+ return `${part.type}:${part.id}`;
130
+ if (part.type === "data") return `data:${part.name}`;
131
+ return part.type;
132
+ }
133
+
134
+ function keyed(content: Content): [Part, string][] {
135
+ const ordinals = new Map<string, number>();
136
+ return content.map((part) => {
137
+ const base = partKey(part);
138
+ const ordinal = ordinals.get(base) ?? 0;
139
+ ordinals.set(base, ordinal + 1);
140
+ return [part, `${base}:${ordinal}`] as [Part, string];
141
+ });
142
+ }
143
+
144
+ function identity(part: Part, key: string, scope: readonly string[]): string {
145
+ return JSON.stringify(
146
+ part.type === "tool-call" ? ["tool", part.toolCallId] : [...scope, key],
147
+ );
148
+ }
149
+
150
+ function visitMessages(
151
+ messages: readonly ThreadMessage[],
152
+ scope: readonly string[],
153
+ visit: (key: string) => void,
154
+ ): void {
155
+ for (const message of messages) {
156
+ const path = [...scope, "message", message.id];
157
+ visit(JSON.stringify(path));
158
+ if (message.role === "assistant")
159
+ visitContent(message.content, path, visit);
160
+ }
161
+ }
162
+
163
+ function visitContent(
164
+ content: Content,
165
+ scope: readonly string[],
166
+ visit: (key: string) => void,
167
+ ): void {
168
+ for (const [part, key] of keyed(content)) {
169
+ visit(identity(part, key, scope));
170
+ if (part.type === "tool-call" && part.messages)
171
+ visitMessages(part.messages, ["tool", part.toolCallId], visit);
172
+ }
173
+ }
174
+
175
+ function pruneMessages(
176
+ messages: readonly ThreadMessage[],
177
+ scope: readonly string[],
178
+ present: ReadonlySet<string>,
179
+ owned: ReadonlySet<string>,
180
+ ): { messages: readonly ThreadMessage[]; hasCurrent: boolean } {
181
+ let hasCurrent = false;
182
+ const kept = messages.flatMap((message) => {
183
+ const path = [...scope, "message", message.id];
184
+ const key = JSON.stringify(path);
185
+ const nested =
186
+ message.role === "assistant"
187
+ ? pruneContent(message.content, path, present, owned)
188
+ : undefined;
189
+ const current = present.has(key) || (nested?.hasCurrent ?? false);
190
+ if (owned.has(key) && !current) return [];
191
+ hasCurrent ||= current;
192
+ return [
193
+ nested && message.role === "assistant"
194
+ ? { ...message, content: nested.content }
195
+ : message,
196
+ ];
197
+ });
198
+ return { messages: kept, hasCurrent };
199
+ }
200
+
201
+ function pruneContent(
202
+ content: Content,
203
+ scope: readonly string[],
204
+ present: ReadonlySet<string>,
205
+ owned: ReadonlySet<string>,
206
+ ): { content: Content; hasCurrent: boolean } {
207
+ let hasCurrent = false;
208
+ const kept = keyed(content).flatMap(([part, localKey]) => {
209
+ const key = identity(part, localKey, scope);
210
+ const nested =
211
+ part.type === "tool-call" && part.messages
212
+ ? pruneMessages(
213
+ part.messages,
214
+ ["tool", part.toolCallId],
215
+ present,
216
+ owned,
217
+ )
218
+ : undefined;
219
+ const current = present.has(key) || (nested?.hasCurrent ?? false);
220
+ if (owned.has(key) && !current) return [];
221
+ hasCurrent ||= current;
222
+ return [nested ? { ...part, messages: nested.messages } : part];
223
+ });
224
+ return { content: kept, hasCurrent };
225
+ }
226
+
227
+ export function createReplayContentMerger(): (
228
+ previous: Content,
229
+ next: Content,
230
+ ) => Content {
231
+ // Remember stream-owned identities, never a second copy of tool payloads.
232
+ const owned = new Set<string>();
233
+ return (previous, next) => {
234
+ const present = new Set<string>();
235
+ visitContent(next, [], (key) => present.add(key));
236
+ for (const key of present) owned.add(key);
237
+ return pruneContent(mergeReplayContent(previous, next), [], present, owned)
238
+ .content;
239
+ };
240
+ }
241
+
242
+ function nestedToolIds(content: Content): Set<string> {
243
+ const nestedIds = new Set<string>();
244
+ for (const part of content) {
245
+ if (part.type !== "tool-call") continue;
246
+ for (const message of part.messages ?? []) {
247
+ if (message.role !== "assistant") continue;
248
+ for (const child of iterateToolCallParts(message.content)) {
249
+ nestedIds.add(child.toolCallId);
250
+ }
251
+ }
252
+ }
253
+ return nestedIds;
254
+ }
255
+
256
+ function mergeReplayContent(previous: Content, next: Content): Content {
257
+ const nestedIds = nestedToolIds(previous);
258
+ const incomingNestedIds = nestedToolIds(next);
259
+ const movingRoots = new Map(
260
+ previous.flatMap((part) =>
261
+ part.type === "tool-call" && incomingNestedIds.has(part.toolCallId)
262
+ ? [[part.toolCallId, part] as const]
263
+ : [],
264
+ ),
265
+ );
266
+ const flattened = new Map<string, ToolCallMessagePart>();
267
+ for (const part of next) {
268
+ if (part.type === "tool-call" && nestedIds.has(part.toolCallId)) {
269
+ flattened.set(part.toolCallId, part);
270
+ }
271
+ }
272
+ const prior = (
273
+ flattened.size
274
+ ? mapToolCallPartsDeep(previous, (part) => {
275
+ const fresh = flattened.get(part.toolCallId);
276
+ return fresh ? mergeTool(part, fresh) : part;
277
+ }).content
278
+ : previous
279
+ ).filter(
280
+ (part) => part.type !== "tool-call" || !movingRoots.has(part.toolCallId),
281
+ );
282
+ let incoming = next.filter(
283
+ (part) => part.type !== "tool-call" || !flattened.has(part.toolCallId),
284
+ );
285
+ if (movingRoots.size) {
286
+ incoming = [
287
+ ...mapToolCallPartsDeep(incoming, (part) => {
288
+ const prior = movingRoots.get(part.toolCallId);
289
+ return prior ? mergeTool(prior, part) : part;
290
+ }).content,
291
+ ];
292
+ }
293
+ return mergeOrdered(keyed(prior), keyed(incoming), (old, fresh) => {
294
+ if (old.type === "tool-call" && fresh.type === "tool-call")
295
+ return mergeTool(old, fresh);
296
+ if (
297
+ (old.type === "text" || old.type === "reasoning") &&
298
+ (fresh.type === "text" || fresh.type === "reasoning") &&
299
+ old.text.startsWith(fresh.text) &&
300
+ old.text.length > fresh.text.length
301
+ ) {
302
+ return { ...fresh, text: old.text };
303
+ }
304
+ return fresh;
305
+ });
306
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ChatModelRunResult } from "@assistant-ui/core";
3
+ import { RunAggregator } from "./run-aggregator";
4
+ import { fromAgUiMessages, toAgUiMessages } from "./conversions";
5
+ import { parseAgUiEvent } from "../event-parser";
6
+
7
+ describe("AG-UI tool result metadata", () => {
8
+ it("keeps interleaved tool result metadata through finish and message export/import", () => {
9
+ const expectedSecondMetadata = {
10
+ trace: "second-final",
11
+ attempts: 0,
12
+ optional: null,
13
+ nested: { value: "after" },
14
+ };
15
+ const updates: ChatModelRunResult[] = [];
16
+ const aggregator = new RunAggregator({
17
+ showThinking: false,
18
+ logger: { debug: () => {}, error: () => {} },
19
+ emit: (update) => updates.push(update),
20
+ });
21
+ aggregator.handle({ type: "RUN_STARTED", runId: "run" });
22
+ for (const toolCallId of ["first", "second", "empty"]) {
23
+ aggregator.handle({
24
+ type: "TOOL_CALL_START",
25
+ toolCallId,
26
+ toolCallName: "lookup",
27
+ });
28
+ }
29
+
30
+ for (const event of [
31
+ {
32
+ type: "TOOL_CALL_RESULT",
33
+ toolCallId: "second",
34
+ messageId: "result-second",
35
+ content: "0",
36
+ metadata: {
37
+ trace: "second",
38
+ attempts: 0,
39
+ optional: null,
40
+ nested: { old: true, value: "before" },
41
+ },
42
+ },
43
+ {
44
+ type: "TOOL_CALL_RESULT",
45
+ toolCallId: "first",
46
+ messageId: "result-first",
47
+ content: "false",
48
+ metadata: { trace: "first", labels: [] },
49
+ },
50
+ {
51
+ type: "TOOL_CALL_RESULT",
52
+ toolCallId: "empty",
53
+ messageId: "result-empty",
54
+ content: "",
55
+ metadata: { trace: "empty" },
56
+ },
57
+ {
58
+ type: "TOOL_CALL_RESULT",
59
+ toolCallId: "second",
60
+ messageId: "result-second",
61
+ content: "0",
62
+ metadata: { trace: "second-final", nested: { value: "after" } },
63
+ },
64
+ ]) {
65
+ const parsed = parseAgUiEvent(event);
66
+ if (!parsed) throw new Error("Expected a parsed result event");
67
+ aggregator.handle(parsed);
68
+ }
69
+
70
+ const liveSecond = updates
71
+ .at(-1)
72
+ ?.content?.find(
73
+ (part) => part.type === "tool-call" && part.toolCallId === "second",
74
+ );
75
+ if (liveSecond?.type !== "tool-call") {
76
+ throw new Error("Expected the streamed second tool call");
77
+ }
78
+ expect(liveSecond).toMatchObject({ result: 0 });
79
+ expect(liveSecond.providerMetadata).toEqual({
80
+ agui: { messageMetadata: expectedSecondMetadata },
81
+ });
82
+ aggregator.handle({ type: "RUN_FINISHED", runId: "run" });
83
+ const content = updates.at(-1)?.content;
84
+ expect(content).toEqual(
85
+ expect.arrayContaining([
86
+ expect.objectContaining({
87
+ toolCallId: "first",
88
+ result: false,
89
+ providerMetadata: {
90
+ agui: { messageMetadata: { trace: "first", labels: [] } },
91
+ },
92
+ }),
93
+ expect.objectContaining({
94
+ toolCallId: "second",
95
+ result: 0,
96
+ providerMetadata: {
97
+ agui: {
98
+ messageMetadata: expectedSecondMetadata,
99
+ },
100
+ },
101
+ }),
102
+ expect.objectContaining({
103
+ toolCallId: "empty",
104
+ result: "",
105
+ providerMetadata: {
106
+ agui: { messageMetadata: { trace: "empty" } },
107
+ },
108
+ }),
109
+ ]),
110
+ );
111
+ const exported = toAgUiMessages([
112
+ { id: "assistant", role: "assistant", content: content ?? [] },
113
+ ]);
114
+ expect(exported).toEqual(
115
+ expect.arrayContaining([
116
+ expect.objectContaining({
117
+ role: "tool",
118
+ toolCallId: "first",
119
+ metadata: { trace: "first", labels: [] },
120
+ }),
121
+ expect.objectContaining({
122
+ role: "tool",
123
+ toolCallId: "second",
124
+ metadata: expectedSecondMetadata,
125
+ }),
126
+ expect.objectContaining({
127
+ role: "tool",
128
+ toolCallId: "empty",
129
+ metadata: { trace: "empty" },
130
+ }),
131
+ ]),
132
+ );
133
+ expect(fromAgUiMessages(exported)).toEqual(
134
+ expect.arrayContaining([
135
+ expect.objectContaining({
136
+ content: expect.arrayContaining([
137
+ expect.objectContaining({
138
+ toolCallId: "second",
139
+ result: 0,
140
+ providerMetadata: {
141
+ agui: {
142
+ messageMetadata: expectedSecondMetadata,
143
+ },
144
+ },
145
+ }),
146
+ ]),
147
+ }),
148
+ ]),
149
+ );
150
+ });
151
+ });