@copilotkit/shared 1.73.0 → 1.73.2-canary.1790068297
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 +22 -0
- package/dist/attachments/content.cjs +17 -0
- package/dist/attachments/content.cjs.map +1 -0
- package/dist/attachments/content.d.cts +9 -0
- package/dist/attachments/content.d.cts.map +1 -0
- package/dist/attachments/content.d.mts +9 -0
- package/dist/attachments/content.d.mts.map +1 -0
- package/dist/attachments/content.mjs +16 -0
- package/dist/attachments/content.mjs.map +1 -0
- package/dist/event-transforms/index.cjs +15 -0
- package/dist/event-transforms/index.d.cts +4 -0
- package/dist/event-transforms/index.d.mts +4 -0
- package/dist/event-transforms/index.mjs +5 -0
- package/dist/event-transforms/open-generative-ui-middleware.cjs +286 -0
- package/dist/event-transforms/open-generative-ui-middleware.cjs.map +1 -0
- package/dist/event-transforms/open-generative-ui-middleware.d.cts +60 -0
- package/dist/event-transforms/open-generative-ui-middleware.d.cts.map +1 -0
- package/dist/event-transforms/open-generative-ui-middleware.d.mts +60 -0
- package/dist/event-transforms/open-generative-ui-middleware.d.mts.map +1 -0
- package/dist/event-transforms/open-generative-ui-middleware.mjs +283 -0
- package/dist/event-transforms/open-generative-ui-middleware.mjs.map +1 -0
- package/dist/event-transforms/recorded-events.cjs +59 -0
- package/dist/event-transforms/recorded-events.cjs.map +1 -0
- package/dist/event-transforms/recorded-events.d.cts +23 -0
- package/dist/event-transforms/recorded-events.d.cts.map +1 -0
- package/dist/event-transforms/recorded-events.d.mts +23 -0
- package/dist/event-transforms/recorded-events.d.mts.map +1 -0
- package/dist/event-transforms/recorded-events.mjs +58 -0
- package/dist/event-transforms/recorded-events.mjs.map +1 -0
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +3 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +224 -1
- package/dist/index.umd.js.map +1 -1
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/dist/types/message.d.cts +3 -3
- package/dist/types/message.d.cts.map +1 -1
- package/dist/types/message.d.mts +3 -3
- package/dist/types/message.d.mts.map +1 -1
- package/dist/utils/index.cjs +1 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.cts +1 -0
- package/dist/utils/index.d.cts.map +1 -1
- package/dist/utils/index.d.mts +1 -0
- package/dist/utils/index.d.mts.map +1 -1
- package/dist/utils/index.mjs +1 -0
- package/dist/utils/index.mjs.map +1 -1
- package/dist/utils/row-render-keys.cjs +206 -0
- package/dist/utils/row-render-keys.cjs.map +1 -0
- package/dist/utils/row-render-keys.d.cts +65 -0
- package/dist/utils/row-render-keys.d.cts.map +1 -0
- package/dist/utils/row-render-keys.d.mts +65 -0
- package/dist/utils/row-render-keys.d.mts.map +1 -0
- package/dist/utils/row-render-keys.mjs +201 -0
- package/dist/utils/row-render-keys.mjs.map +1 -0
- package/package.json +12 -1
- package/src/__tests__/root-entry-browser-safety.test.ts +7 -1
- package/src/attachments/content.test.ts +48 -0
- package/src/attachments/content.ts +16 -0
- package/src/attachments/index.ts +2 -0
- package/src/event-transforms/index.ts +5 -0
- package/src/event-transforms/open-generative-ui-middleware.ts +392 -0
- package/src/event-transforms/recorded-events.test.ts +166 -0
- package/src/event-transforms/recorded-events.ts +74 -0
- package/src/utils/__tests__/row-render-keys.test.ts +388 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/row-render-keys.ts +263 -0
- package/tsdown.config.ts +7 -4
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Message, ToolCall } from "@ag-ui/core";
|
|
3
|
+
import {
|
|
4
|
+
commitRowKeyStore,
|
|
5
|
+
createRowKeyStore,
|
|
6
|
+
pruneRowKeyStore,
|
|
7
|
+
resolveRowRenderKeys,
|
|
8
|
+
resolveRowRenderKeysById,
|
|
9
|
+
} from "../row-render-keys";
|
|
10
|
+
|
|
11
|
+
function toolCall(id: string, name = "approve"): ToolCall {
|
|
12
|
+
return { id, type: "function", function: { name, arguments: "{}" } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assistant(id: string, toolCalls?: ToolCall[]): Message {
|
|
16
|
+
return { id, role: "assistant", content: "…", toolCalls } as Message;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function user(id: string): Message {
|
|
20
|
+
return { id, role: "user", content: "hi" } as Message;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One render that reaches the screen: resolve keys, then record anchors the way
|
|
25
|
+
* each framework's post-render phase does. Anchors only exist after a render,
|
|
26
|
+
* so a test that spans renders has to commit between them.
|
|
27
|
+
*/
|
|
28
|
+
function renderPass(
|
|
29
|
+
store: ReturnType<typeof createRowKeyStore>,
|
|
30
|
+
messages: Message[],
|
|
31
|
+
) {
|
|
32
|
+
const keys = resolveRowRenderKeysById(store, messages);
|
|
33
|
+
commitRowKeyStore(store, messages);
|
|
34
|
+
return keys;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The same, for callers that key rows by position rather than by id. */
|
|
38
|
+
function renderPassByPosition(
|
|
39
|
+
store: ReturnType<typeof createRowKeyStore>,
|
|
40
|
+
messages: readonly (Message | undefined)[],
|
|
41
|
+
) {
|
|
42
|
+
const keys = resolveRowRenderKeys(store, messages);
|
|
43
|
+
commitRowKeyStore(store, messages);
|
|
44
|
+
return keys;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The row key a message resolves to for a given list. */
|
|
48
|
+
function keyOf(
|
|
49
|
+
store: ReturnType<typeof createRowKeyStore>,
|
|
50
|
+
messages: Message[],
|
|
51
|
+
id: string,
|
|
52
|
+
) {
|
|
53
|
+
return renderPass(store, messages).get(id);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("resolveRowRenderKeys", () => {
|
|
57
|
+
it("keys by message.id when there are no tool calls, leaving the store empty", () => {
|
|
58
|
+
const store = createRowKeyStore();
|
|
59
|
+
const messages = [user("u1"), assistant("a1")];
|
|
60
|
+
|
|
61
|
+
const keys = resolveRowRenderKeysById(store, messages);
|
|
62
|
+
|
|
63
|
+
expect(keys.get("u1")).toBe("u1");
|
|
64
|
+
expect(keys.get("a1")).toBe("a1");
|
|
65
|
+
// The whole point of the override-table design: unaffected conversations
|
|
66
|
+
// carry no state and behave exactly like plain id keying.
|
|
67
|
+
expect(store.overrides.size).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("holds the key steady when a tool call appears on an already-streaming message", () => {
|
|
71
|
+
// Real sequence: TEXT_MESSAGE_START creates the message, TOOL_CALL_START
|
|
72
|
+
// then mutates that SAME message. Deriving the key from the tool call
|
|
73
|
+
// would change it here and remount the row.
|
|
74
|
+
const store = createRowKeyStore();
|
|
75
|
+
|
|
76
|
+
const streaming = keyOf(store, [assistant("lc_run--1")], "lc_run--1");
|
|
77
|
+
const withTool = keyOf(
|
|
78
|
+
store,
|
|
79
|
+
[assistant("lc_run--1", [toolCall("call_A")])],
|
|
80
|
+
"lc_run--1",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(streaming).toBe("lc_run--1");
|
|
84
|
+
expect(withTool).toBe("lc_run--1");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("holds the key steady when the snapshot re-keys the message id", () => {
|
|
88
|
+
const store = createRowKeyStore();
|
|
89
|
+
|
|
90
|
+
renderPass(store, [assistant("lc_run--1", [toolCall("call_A")])]);
|
|
91
|
+
const afterRekey = keyOf(
|
|
92
|
+
store,
|
|
93
|
+
[assistant("resp_1", [toolCall("call_A")])],
|
|
94
|
+
"resp_1",
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(afterRekey).toBe("lc_run--1");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("holds the key steady across the full streaming sequence", () => {
|
|
101
|
+
const store = createRowKeyStore();
|
|
102
|
+
|
|
103
|
+
const k1 = keyOf(store, [assistant("lc_run--1")], "lc_run--1");
|
|
104
|
+
const k2 = keyOf(
|
|
105
|
+
store,
|
|
106
|
+
[assistant("lc_run--1", [toolCall("call_A")])],
|
|
107
|
+
"lc_run--1",
|
|
108
|
+
);
|
|
109
|
+
const k3 = keyOf(
|
|
110
|
+
store,
|
|
111
|
+
[assistant("resp_1", [toolCall("call_A")])],
|
|
112
|
+
"resp_1",
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(new Set([k1, k2, k3]).size).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("resolves through a surviving anchor when tool calls are reordered", () => {
|
|
119
|
+
const store = createRowKeyStore();
|
|
120
|
+
|
|
121
|
+
renderPass(store, [
|
|
122
|
+
assistant("lc_run--1", [toolCall("call_A"), toolCall("call_B")]),
|
|
123
|
+
]);
|
|
124
|
+
// Snapshot re-keys the message AND presents the tool calls in the other
|
|
125
|
+
// order — anchoring on every tool call (not just the first) survives it.
|
|
126
|
+
const afterRekey = keyOf(
|
|
127
|
+
store,
|
|
128
|
+
[assistant("resp_1", [toolCall("call_B"), toolCall("call_A")])],
|
|
129
|
+
"resp_1",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(afterRekey).toBe("lc_run--1");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("keeps a later tool call from stealing an established key", () => {
|
|
136
|
+
const store = createRowKeyStore();
|
|
137
|
+
|
|
138
|
+
renderPass(store, [assistant("lc_run--1", [toolCall("call_A")])]);
|
|
139
|
+
// A second tool call arrives on the same row; call_A already owns the key.
|
|
140
|
+
const keys = renderPass(store, [
|
|
141
|
+
assistant("lc_run--1", [toolCall("call_A"), toolCall("call_B")]),
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
expect(keys.get("lc_run--1")).toBe("lc_run--1");
|
|
145
|
+
expect(store.overrides.get("tc:call_B")).toBe("lc_run--1");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("falls back to message.id when two messages share a tool-call id", () => {
|
|
149
|
+
const store = createRowKeyStore();
|
|
150
|
+
const messages = [
|
|
151
|
+
assistant("a-1", [toolCall("call_X")]),
|
|
152
|
+
assistant("a-2", [toolCall("call_X")]),
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const keys = resolveRowRenderKeysById(store, messages);
|
|
156
|
+
|
|
157
|
+
expect(keys.get("a-1")).toBe("a-1");
|
|
158
|
+
expect(keys.get("a-2")).toBe("a-2");
|
|
159
|
+
expect(new Set(keys.values()).size).toBe(2);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("keeps a shared-anchor fallback stable across renders", () => {
|
|
163
|
+
// The loser of a shared anchor must not drift between renders, or it
|
|
164
|
+
// remounts on every update.
|
|
165
|
+
const store = createRowKeyStore();
|
|
166
|
+
const messages = [
|
|
167
|
+
assistant("a-1", [toolCall("call_X")]),
|
|
168
|
+
assistant("a-2", [toolCall("call_X")]),
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const first = renderPass(store, messages);
|
|
172
|
+
const second = renderPass(store, messages);
|
|
173
|
+
|
|
174
|
+
expect(second.get("a-1")).toBe(first.get("a-1"));
|
|
175
|
+
expect(second.get("a-2")).toBe(first.get("a-2"));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("disambiguates when an override vends a key equal to a later message's id", () => {
|
|
179
|
+
// Pathological: the store vends "ghost" for one row, and another message
|
|
180
|
+
// literally has id "ghost". Keys must still be unique.
|
|
181
|
+
const store = createRowKeyStore();
|
|
182
|
+
renderPass(store, [assistant("ghost", [toolCall("call_X")])]);
|
|
183
|
+
|
|
184
|
+
const keys = resolveRowRenderKeysById(store, [
|
|
185
|
+
assistant("live", [toolCall("call_X")]),
|
|
186
|
+
assistant("ghost"),
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
expect(keys.get("live")).toBe("ghost");
|
|
190
|
+
expect(keys.get("ghost")).not.toBe("ghost");
|
|
191
|
+
expect(new Set(keys.values()).size).toBe(2);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("is idempotent, so a repeated or abandoned render yields the same keys", () => {
|
|
195
|
+
const store = createRowKeyStore();
|
|
196
|
+
const messages = [
|
|
197
|
+
assistant("a-1", [toolCall("call_A")]),
|
|
198
|
+
assistant("a-2", [toolCall("call_B")]),
|
|
199
|
+
user("u-1"),
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
const first = renderPass(store, messages);
|
|
203
|
+
const snapshot = new Map(store.overrides);
|
|
204
|
+
const second = renderPass(store, messages);
|
|
205
|
+
|
|
206
|
+
expect([...second.entries()]).toEqual([...first.entries()]);
|
|
207
|
+
expect([...store.overrides.entries()]).toEqual([...snapshot.entries()]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("leaves the store untouched, so a render is safe to discard", () => {
|
|
211
|
+
const store = createRowKeyStore();
|
|
212
|
+
|
|
213
|
+
resolveRowRenderKeys(store, [assistant("lc_run--1", [toolCall("call_A")])]);
|
|
214
|
+
|
|
215
|
+
expect(store.overrides.size).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not let a discarded render's key reach the rendered rows", () => {
|
|
219
|
+
// Every framework here can evaluate a render pass and then throw it away.
|
|
220
|
+
// If resolving had recorded tc:call_A -> resp_1, the row the user is
|
|
221
|
+
// actually looking at (still lc_run--1) would re-key and be torn down on
|
|
222
|
+
// its next render — the flash this module exists to prevent.
|
|
223
|
+
const store = createRowKeyStore();
|
|
224
|
+
|
|
225
|
+
resolveRowRenderKeys(store, [assistant("resp_1", [toolCall("call_A")])]);
|
|
226
|
+
const committed = keyOf(
|
|
227
|
+
store,
|
|
228
|
+
[assistant("lc_run--1", [toolCall("call_A")])],
|
|
229
|
+
"lc_run--1",
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(committed).toBe("lc_run--1");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("records anchors once the render reaches the DOM", () => {
|
|
236
|
+
const store = createRowKeyStore();
|
|
237
|
+
const messages = [assistant("lc_run--1", [toolCall("call_A")])];
|
|
238
|
+
|
|
239
|
+
resolveRowRenderKeys(store, messages);
|
|
240
|
+
commitRowKeyStore(store, messages);
|
|
241
|
+
|
|
242
|
+
expect(store.overrides.get("tc:call_A")).toBe("lc_run--1");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("does not hand a shared anchor's key to the survivor when the other row leaves", () => {
|
|
246
|
+
// Two rows carrying one tool-call id make that anchor useless as an
|
|
247
|
+
// identity: whichever row outlives the other would inherit the first
|
|
248
|
+
// row's key, and with it the first row's DOM and component state.
|
|
249
|
+
const store = createRowKeyStore();
|
|
250
|
+
renderPass(store, [
|
|
251
|
+
assistant("a-1", [toolCall("call_X")]),
|
|
252
|
+
assistant("a-2", [toolCall("call_X")]),
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
const survivor = keyOf(
|
|
256
|
+
store,
|
|
257
|
+
[assistant("a-2", [toolCall("call_X")])],
|
|
258
|
+
"a-2",
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
expect(survivor).toBe("a-2");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("does not register anchors for non-assistant roles", () => {
|
|
265
|
+
const store = createRowKeyStore();
|
|
266
|
+
const toolMessage = {
|
|
267
|
+
id: "t-1",
|
|
268
|
+
role: "tool",
|
|
269
|
+
content: "ok",
|
|
270
|
+
toolCallId: "call_A",
|
|
271
|
+
} as unknown as Message;
|
|
272
|
+
|
|
273
|
+
renderPass(store, [toolMessage]);
|
|
274
|
+
|
|
275
|
+
expect(store.overrides.size).toBe(0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("ignores an empty toolCalls array", () => {
|
|
279
|
+
const store = createRowKeyStore();
|
|
280
|
+
|
|
281
|
+
const keys = renderPass(store, [assistant("a-1", [])]);
|
|
282
|
+
|
|
283
|
+
expect(keys.get("a-1")).toBe("a-1");
|
|
284
|
+
expect(store.overrides.size).toBe(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("still re-keys a text-only message (documented limitation)", () => {
|
|
288
|
+
// No anchor exists for a text-only message, so nothing can correlate it
|
|
289
|
+
// across a rename. Recorded so a future change that fixes it is a
|
|
290
|
+
// deliberate update rather than an accident.
|
|
291
|
+
const store = createRowKeyStore();
|
|
292
|
+
|
|
293
|
+
const before = keyOf(store, [assistant("lc_run--1")], "lc_run--1");
|
|
294
|
+
const after = keyOf(store, [assistant("resp_1")], "resp_1");
|
|
295
|
+
|
|
296
|
+
expect(before).toBe("lc_run--1");
|
|
297
|
+
expect(after).toBe("resp_1");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("resolveRowRenderKeys by position", () => {
|
|
302
|
+
it("keeps keys unique when the list contains duplicate message ids", () => {
|
|
303
|
+
// A caller that does not deduplicate lets duplicate ids reach the view, and
|
|
304
|
+
// Angular reports duplicated track values as an error, so uniqueness has to
|
|
305
|
+
// be structural.
|
|
306
|
+
const store = createRowKeyStore();
|
|
307
|
+
|
|
308
|
+
const keys = renderPassByPosition(store, [
|
|
309
|
+
assistant("dup"),
|
|
310
|
+
assistant("dup"),
|
|
311
|
+
assistant("dup"),
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
expect(new Set(keys).size).toBe(3);
|
|
315
|
+
expect(keys[0]).toBe("dup");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("falls back to the index for a message with no id", () => {
|
|
319
|
+
const store = createRowKeyStore();
|
|
320
|
+
const idless = { role: "assistant", content: "x" } as unknown as Message;
|
|
321
|
+
|
|
322
|
+
const keys = renderPassByPosition(store, [assistant("a-1"), idless]);
|
|
323
|
+
|
|
324
|
+
expect(keys).toEqual(["a-1", "index-1"]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("tolerates a sparse list without throwing", () => {
|
|
328
|
+
const store = createRowKeyStore();
|
|
329
|
+
|
|
330
|
+
const keys = renderPassByPosition(store, [undefined, assistant("a-1")]);
|
|
331
|
+
|
|
332
|
+
expect(keys).toEqual(["index-0", "a-1"]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("agrees with the id-keyed shape on a deduplicated list", () => {
|
|
336
|
+
const messages = [
|
|
337
|
+
assistant("lc_run--1", [toolCall("call_A")]),
|
|
338
|
+
user("u-1"),
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
const byPosition = renderPassByPosition(createRowKeyStore(), messages);
|
|
342
|
+
const byId = renderPass(createRowKeyStore(), messages);
|
|
343
|
+
|
|
344
|
+
expect(byPosition).toEqual(messages.map((m) => byId.get(m.id)));
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("pruneRowKeyStore", () => {
|
|
349
|
+
it("drops anchors whose messages are gone and keeps live ones", () => {
|
|
350
|
+
const store = createRowKeyStore();
|
|
351
|
+
renderPass(store, [
|
|
352
|
+
assistant("a-1", [toolCall("call_A")]),
|
|
353
|
+
assistant("a-2", [toolCall("call_B")]),
|
|
354
|
+
]);
|
|
355
|
+
expect(store.overrides.size).toBe(2);
|
|
356
|
+
|
|
357
|
+
pruneRowKeyStore(store, [assistant("a-2", [toolCall("call_B")])]);
|
|
358
|
+
|
|
359
|
+
expect([...store.overrides.keys()]).toEqual(["tc:call_B"]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("bounds the store to the visible list rather than the conversation", () => {
|
|
363
|
+
const store = createRowKeyStore();
|
|
364
|
+
for (let i = 0; i < 50; i++) {
|
|
365
|
+
const live = [assistant(`a-${i}`, [toolCall(`call_${i}`)])];
|
|
366
|
+
renderPass(store, live);
|
|
367
|
+
pruneRowKeyStore(store, live);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
expect(store.overrides.size).toBe(1);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("preserves the key of a row that survives a prune", () => {
|
|
374
|
+
const store = createRowKeyStore();
|
|
375
|
+
const live = [assistant("lc_run--1", [toolCall("call_A")])];
|
|
376
|
+
renderPass(store, live);
|
|
377
|
+
pruneRowKeyStore(store, live);
|
|
378
|
+
|
|
379
|
+
// The re-key must still resolve after an intervening prune.
|
|
380
|
+
const afterRekey = keyOf(
|
|
381
|
+
store,
|
|
382
|
+
[assistant("resp_1", [toolCall("call_A")])],
|
|
383
|
+
"resp_1",
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
expect(afterRekey).toBe("lc_run--1");
|
|
387
|
+
});
|
|
388
|
+
});
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { AssistantMessage, Message } from "@ag-ui/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable per-row view keys for chat transcripts, across backends that re-key a
|
|
5
|
+
* message mid-stream. Shared by every frontend package, because the defect and
|
|
6
|
+
* the correlation signal are in the message stream, not in any one framework.
|
|
7
|
+
*
|
|
8
|
+
* ## The problem
|
|
9
|
+
*
|
|
10
|
+
* A message's canonical `id` is not stable within a turn. LangChain stamps a
|
|
11
|
+
* placeholder id on the first streamed chunk when the provider didn't supply
|
|
12
|
+
* one (`chat_models.py`: `if chunk.message.id is None: chunk.message.id =
|
|
13
|
+
* "lc_run-" + "-" + run_id`), then prefers any provider-assigned id it sees
|
|
14
|
+
* while merging chunks (`messages/ai.py`, "Ranks are defined by the order of
|
|
15
|
+
* preference"). The `MESSAGES_SNAPSHOT` therefore carries the provider's final
|
|
16
|
+
* id (e.g. `resp_…`) for a message the client already knows as `lc_run--…`.
|
|
17
|
+
*
|
|
18
|
+
* Every framework destroys and recreates a row whose key changes, so keying
|
|
19
|
+
* rows by `id` turned that swap into the visible HITL chat flash, where a
|
|
20
|
+
* rendered approval card appears to reset during a tool's
|
|
21
|
+
* `executing → complete` transition.
|
|
22
|
+
*
|
|
23
|
+
* This is provider-conditional: providers that stamp an id on every chunk (so
|
|
24
|
+
* `chunk.message.id is None` never holds) never trigger a rename.
|
|
25
|
+
*
|
|
26
|
+
* ## The approach
|
|
27
|
+
*
|
|
28
|
+
* Tool-call ids survive the rename, so they are used as *anchors*: the first
|
|
29
|
+
* message seen carrying a given tool call records the key it was assigned, and
|
|
30
|
+
* any later message carrying that same tool call reuses it. The store is an
|
|
31
|
+
* override table consulted before falling back to `message.id`. Resolving only
|
|
32
|
+
* reads it; each render that reaches the DOM then records what it rendered:
|
|
33
|
+
*
|
|
34
|
+
* ```text
|
|
35
|
+
* render 1 id=lc_run--1 no tools key = message.id = lc_run--1
|
|
36
|
+
* commit 1 store {}
|
|
37
|
+
* render 2 id=lc_run--1 tc call_A key = message.id = lc_run--1
|
|
38
|
+
* commit 2 store { tc:call_A -> lc_run--1 }
|
|
39
|
+
* render 3 id=resp_1 tc call_A key = store[tc:call_A]
|
|
40
|
+
* = lc_run--1 (row survives)
|
|
41
|
+
* commit 3 store unchanged
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* Recording the anchor at commit 2 — while the id is still stable — is what
|
|
45
|
+
* makes render 3 resolvable. Note the fix does not depend on render 2
|
|
46
|
+
* existing: a message born already carrying a tool call anchors on the first
|
|
47
|
+
* render that commits it, under whatever id it holds then.
|
|
48
|
+
*
|
|
49
|
+
* ## Why an override table rather than keying rows by tool-call id
|
|
50
|
+
*
|
|
51
|
+
* Deriving the key from the tool call directly (`tc:<id>` whenever a tool call
|
|
52
|
+
* is present) needs no state, but changes the key the moment a tool call
|
|
53
|
+
* *appears* — so an assistant message that streams text and then calls a tool
|
|
54
|
+
* is torn down on that transition, for every provider, whether or not it
|
|
55
|
+
* renames. That trades a conditional flash for an unconditional one. Recording
|
|
56
|
+
* an override keeps the key the row already had.
|
|
57
|
+
*
|
|
58
|
+
* ## Cost
|
|
59
|
+
*
|
|
60
|
+
* The store holds only `tc:<toolCallId>` entries, so a conversation with no
|
|
61
|
+
* tool calls keeps an empty store and behaviour byte-identical to keying by
|
|
62
|
+
* `id`. Size is bounded to the tool calls of the currently-rendered messages
|
|
63
|
+
* (see `pruneRowKeyStore`), not the conversation length. Deleting the store
|
|
64
|
+
* reverts to plain `id` keying.
|
|
65
|
+
*
|
|
66
|
+
* ## Known gaps
|
|
67
|
+
*
|
|
68
|
+
* - A text-only assistant message has no anchor, so it still re-keys. No
|
|
69
|
+
* client-side correlation signal exists for it; the fix is stable ids
|
|
70
|
+
* upstream.
|
|
71
|
+
* - If the tool call's arrival and the id swap land in the same update, the
|
|
72
|
+
* intermediate state never renders, the anchor is never recorded, and the
|
|
73
|
+
* row is recreated as before. Correlating in the event-apply layer (i.e. in
|
|
74
|
+
* the AG-UI client, which observes every intermediate state) would be immune.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
const TOOL_ANCHOR_PREFIX = "tc:";
|
|
78
|
+
|
|
79
|
+
export interface RowKeyStore {
|
|
80
|
+
/**
|
|
81
|
+
* `tc:<toolCallId>` → the row key first assigned to a message carrying that
|
|
82
|
+
* tool call. Populated only for assistant messages that carry tool calls.
|
|
83
|
+
*/
|
|
84
|
+
overrides: Map<string, string>;
|
|
85
|
+
/**
|
|
86
|
+
* Anchors carried by more than one message in the last rendered list. Such
|
|
87
|
+
* an anchor identifies no single row, so it is never used to vend a key.
|
|
88
|
+
*/
|
|
89
|
+
ambiguous: Set<string>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createRowKeyStore(): RowKeyStore {
|
|
93
|
+
return { overrides: new Map(), ambiguous: new Set() };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Anchors a message contributes. Only assistant tool calls qualify: LangChain's
|
|
98
|
+
* id preference applies when merging `AIMessageChunk`s, so user message ids are
|
|
99
|
+
* not renamed, and `role: "tool"` messages are not rendered as rows. Every tool
|
|
100
|
+
* call is used (not just the first) so the anchor survives tool-call reordering
|
|
101
|
+
* between snapshots.
|
|
102
|
+
*/
|
|
103
|
+
function toolAnchorsOf(message: Message | undefined): string[] {
|
|
104
|
+
if (message?.role !== "assistant") return [];
|
|
105
|
+
const toolCalls = (message as AssistantMessage).toolCalls;
|
|
106
|
+
if (!toolCalls?.length) return [];
|
|
107
|
+
|
|
108
|
+
const anchors: string[] = [];
|
|
109
|
+
for (const toolCall of toolCalls) {
|
|
110
|
+
if (toolCall?.id) anchors.push(`${TOOL_ANCHOR_PREFIX}${toolCall.id}`);
|
|
111
|
+
}
|
|
112
|
+
return anchors;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolves the row key for every message, returned by position.
|
|
117
|
+
*
|
|
118
|
+
* Pure: it reads `store` and never writes to it. Every framework here may
|
|
119
|
+
* evaluate a render pass whose result never reaches the DOM — an abandoned
|
|
120
|
+
* concurrent render in React, a discarded `computed` evaluation in Vue or
|
|
121
|
+
* Angular. An anchor recorded by such a pass would vend its key to the pass
|
|
122
|
+
* that does render, re-keying the row the user is looking at, which is the
|
|
123
|
+
* teardown this module exists to prevent. Anchors are recorded by
|
|
124
|
+
* `commitRowKeyStore`, from whichever phase each framework runs after the DOM
|
|
125
|
+
* is updated.
|
|
126
|
+
*
|
|
127
|
+
* Uniqueness is structural rather than assumed: a caller that does not
|
|
128
|
+
* deduplicate can pass two rows with the same id, and an override can vend a
|
|
129
|
+
* key equal to a later message's own id.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveRowRenderKeys(
|
|
132
|
+
store: RowKeyStore,
|
|
133
|
+
messages: readonly (Message | undefined)[],
|
|
134
|
+
): string[] {
|
|
135
|
+
const keys: string[] = [];
|
|
136
|
+
const claimed = new Set<string>();
|
|
137
|
+
|
|
138
|
+
messages.forEach((message, index) => {
|
|
139
|
+
const anchors = toolAnchorsOf(message);
|
|
140
|
+
|
|
141
|
+
// Reuse the key recorded for any of this message's anchors. An override
|
|
142
|
+
// pointing at a key another row already claimed this pass is skipped: two
|
|
143
|
+
// messages can share a tool-call id (upstream bug, or replayed state), and
|
|
144
|
+
// the later one falls back to its own id instead.
|
|
145
|
+
let key: string | undefined;
|
|
146
|
+
for (const anchor of anchors) {
|
|
147
|
+
if (store.ambiguous.has(anchor)) continue;
|
|
148
|
+
const recorded = store.overrides.get(anchor);
|
|
149
|
+
if (recorded !== undefined && !claimed.has(recorded)) {
|
|
150
|
+
key = recorded;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The index fallback covers a message with no usable id.
|
|
156
|
+
key ??= message?.id || `index-${index}`;
|
|
157
|
+
|
|
158
|
+
if (claimed.has(key)) {
|
|
159
|
+
let suffix = 2;
|
|
160
|
+
while (claimed.has(`${key}:${suffix}`)) suffix += 1;
|
|
161
|
+
key = `${key}:${suffix}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
keys.push(key);
|
|
165
|
+
claimed.add(key);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return keys;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `message.id` → row key, for callers that render by message rather than by
|
|
173
|
+
* position. `messages` must be deduplicated: duplicate ids would overwrite
|
|
174
|
+
* each other in the returned map.
|
|
175
|
+
*/
|
|
176
|
+
export function resolveRowRenderKeysById(
|
|
177
|
+
store: RowKeyStore,
|
|
178
|
+
messages: readonly Message[],
|
|
179
|
+
): Map<string, string> {
|
|
180
|
+
const keys = resolveRowRenderKeys(store, messages);
|
|
181
|
+
const byId = new Map<string, string>();
|
|
182
|
+
messages.forEach((message, index) => {
|
|
183
|
+
const key = keys[index];
|
|
184
|
+
if (key !== undefined) byId.set(message.id, key);
|
|
185
|
+
});
|
|
186
|
+
return byId;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Records the anchors of a rendered list, then bounds the store to it. Call
|
|
191
|
+
* from the phase that runs after the DOM is updated, never while resolving —
|
|
192
|
+
* see `resolveRowRenderKeys`.
|
|
193
|
+
*
|
|
194
|
+
* Re-resolving here reproduces the keys the rows were rendered with, because
|
|
195
|
+
* the store cannot change between a render and its own post-render phase.
|
|
196
|
+
*
|
|
197
|
+
* An anchor carried by two rows in the same list is marked ambiguous rather
|
|
198
|
+
* than recorded. Recording it would give the first row's key to whichever row
|
|
199
|
+
* outlived the other, and with it that row's DOM and component state.
|
|
200
|
+
*/
|
|
201
|
+
export function commitRowKeyStore(
|
|
202
|
+
store: RowKeyStore,
|
|
203
|
+
messages: readonly (Message | undefined)[],
|
|
204
|
+
): void {
|
|
205
|
+
const keys = resolveRowRenderKeys(store, messages);
|
|
206
|
+
|
|
207
|
+
const anchorCounts = new Map<string, number>();
|
|
208
|
+
for (const message of messages) {
|
|
209
|
+
for (const anchor of toolAnchorsOf(message)) {
|
|
210
|
+
anchorCounts.set(anchor, (anchorCounts.get(anchor) ?? 0) + 1);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Ambiguity is a property of the rendered list, so it is recomputed rather
|
|
215
|
+
// than accumulated: an anchor left alone by the row that shadowed it becomes
|
|
216
|
+
// usable again.
|
|
217
|
+
store.ambiguous.clear();
|
|
218
|
+
for (const [anchor, count] of anchorCounts) {
|
|
219
|
+
if (count > 1) {
|
|
220
|
+
store.ambiguous.add(anchor);
|
|
221
|
+
store.overrides.delete(anchor);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// First claimant of an anchor owns it, so a re-keyed message resolves to the
|
|
226
|
+
// key the row already had rather than overwriting it.
|
|
227
|
+
messages.forEach((message, index) => {
|
|
228
|
+
const key = keys[index];
|
|
229
|
+
if (key === undefined) return;
|
|
230
|
+
for (const anchor of toolAnchorsOf(message)) {
|
|
231
|
+
if (store.ambiguous.has(anchor)) continue;
|
|
232
|
+
if (!store.overrides.has(anchor)) store.overrides.set(anchor, key);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
pruneRowKeyStore(store, messages);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Drops anchors no longer present in `messages`, bounding the store to the
|
|
241
|
+
* tool calls of the currently-rendered messages. Called by
|
|
242
|
+
* `commitRowKeyStore`; like it, this writes to the store and so belongs after
|
|
243
|
+
* the DOM is updated, never while resolving.
|
|
244
|
+
*
|
|
245
|
+
* Pruned entries are unreachable by construction: `resolveRowRenderKeys` only
|
|
246
|
+
* looks up anchors belonging to messages in the list it is given.
|
|
247
|
+
*/
|
|
248
|
+
export function pruneRowKeyStore(
|
|
249
|
+
store: RowKeyStore,
|
|
250
|
+
messages: readonly (Message | undefined)[],
|
|
251
|
+
): void {
|
|
252
|
+
const live = new Set<string>();
|
|
253
|
+
for (const message of messages) {
|
|
254
|
+
for (const anchor of toolAnchorsOf(message)) live.add(anchor);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const anchor of store.overrides.keys()) {
|
|
258
|
+
if (!live.has(anchor)) store.overrides.delete(anchor);
|
|
259
|
+
}
|
|
260
|
+
for (const anchor of store.ambiguous) {
|
|
261
|
+
if (!live.has(anchor)) store.ambiguous.delete(anchor);
|
|
262
|
+
}
|
|
263
|
+
}
|
package/tsdown.config.ts
CHANGED
|
@@ -2,10 +2,13 @@ import { defineConfig } from "tsdown";
|
|
|
2
2
|
|
|
3
3
|
export default defineConfig([
|
|
4
4
|
{
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
// Node-only telemetry and event transforms have separate entries.
|
|
6
|
+
// The browser-facing root must not re-export either (#4151).
|
|
7
|
+
entry: [
|
|
8
|
+
"src/index.ts",
|
|
9
|
+
"src/telemetry/index.ts",
|
|
10
|
+
"src/event-transforms/index.ts",
|
|
11
|
+
],
|
|
9
12
|
format: ["esm", "cjs"],
|
|
10
13
|
dts: true,
|
|
11
14
|
sourcemap: true,
|