@assistant-ui/react-ag-ui 0.0.71 → 0.1.1
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.
- package/README.md +1 -1
- package/dist/agUiExtras.js +1 -2
- package/dist/hooks.js +1 -2
- package/dist/runtime/{AgUiThreadRuntimeCore.d.ts → AgUiThreadController.d.ts} +6 -12
- package/dist/runtime/AgUiThreadController.d.ts.map +1 -0
- package/dist/runtime/{AgUiThreadRuntimeCore.js → AgUiThreadController.js} +567 -194
- package/dist/runtime/AgUiThreadController.js.map +1 -0
- package/dist/runtime/adapter/conversions.d.ts +4 -4
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +120 -35
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/replay-content.d.ts +9 -0
- package/dist/runtime/adapter/replay-content.d.ts.map +1 -0
- package/dist/runtime/adapter/replay-content.js +185 -0
- package/dist/runtime/adapter/replay-content.js.map +1 -0
- package/dist/runtime/adapter/run-aggregator.d.ts +3 -5
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +48 -63
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +0 -24
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +2 -24
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +2 -0
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
- package/dist/runtime/adapter/tool-approval.js +23 -6
- package/dist/runtime/adapter/tool-approval.js.map +1 -1
- package/dist/runtime/agUiThreadState.d.ts +60 -0
- package/dist/runtime/agUiThreadState.d.ts.map +1 -0
- package/dist/runtime/agUiThreadState.js +94 -0
- package/dist/runtime/agUiThreadState.js.map +1 -0
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +46 -50
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/interrupt-internals.js +1 -2
- package/dist/runtime/logger.js +1 -2
- package/dist/runtime/mcp-tool-result.d.ts +3 -1
- package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
- package/dist/runtime/mcp-tool-result.js +7 -5
- package/dist/runtime/mcp-tool-result.js.map +1 -1
- package/dist/runtime/types.d.ts +11 -35
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js +1 -2
- package/dist/runtime/types.js.map +1 -1
- package/dist/useAgUiRuntime.js +3 -4
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +12 -11
- package/src/runtime/AgUiThreadController.deferred-results.test.ts +548 -0
- package/src/runtime/{AgUiThreadRuntimeCore.interrupt-cancel.test.ts → AgUiThreadController.interrupt-cancel.test.ts} +4 -4
- package/src/runtime/AgUiThreadController.replay.test.ts +3932 -0
- package/src/runtime/{AgUiThreadRuntimeCore.test.ts → AgUiThreadController.test.ts} +114 -6
- package/src/runtime/{AgUiThreadRuntimeCore.ts → AgUiThreadController.ts} +1037 -236
- package/src/runtime/adapter/conversions.content.test.ts +149 -5
- package/src/runtime/adapter/conversions.tools.test.ts +2 -1
- package/src/runtime/adapter/conversions.ts +249 -62
- package/src/runtime/adapter/replay-content.test.ts +235 -0
- package/src/runtime/adapter/replay-content.ts +306 -0
- package/src/runtime/adapter/run-aggregator.ts +79 -114
- package/src/runtime/adapter/subscriber.ts +1 -44
- package/src/runtime/adapter/tool-approval.ts +41 -3
- package/src/runtime/agUiThreadState.test.ts +224 -0
- package/src/runtime/agUiThreadState.ts +139 -0
- package/src/runtime/event-parser.ts +40 -58
- package/src/runtime/mcp-tool-result.ts +16 -2
- package/src/runtime/types.ts +16 -28
- package/src/useAgUiRuntime.fast-refresh.test.tsx +3 -3
- package/src/useAgUiRuntime.thread-switch.test.tsx +4 -4
- package/src/useAgUiRuntime.ts +3 -3
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +0 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +0 -1
|
@@ -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
|
+
}
|