@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.
Files changed (74) hide show
  1. package/README.md +22 -0
  2. package/dist/attachments/content.cjs +17 -0
  3. package/dist/attachments/content.cjs.map +1 -0
  4. package/dist/attachments/content.d.cts +9 -0
  5. package/dist/attachments/content.d.cts.map +1 -0
  6. package/dist/attachments/content.d.mts +9 -0
  7. package/dist/attachments/content.d.mts.map +1 -0
  8. package/dist/attachments/content.mjs +16 -0
  9. package/dist/attachments/content.mjs.map +1 -0
  10. package/dist/event-transforms/index.cjs +15 -0
  11. package/dist/event-transforms/index.d.cts +4 -0
  12. package/dist/event-transforms/index.d.mts +4 -0
  13. package/dist/event-transforms/index.mjs +5 -0
  14. package/dist/event-transforms/open-generative-ui-middleware.cjs +286 -0
  15. package/dist/event-transforms/open-generative-ui-middleware.cjs.map +1 -0
  16. package/dist/event-transforms/open-generative-ui-middleware.d.cts +60 -0
  17. package/dist/event-transforms/open-generative-ui-middleware.d.cts.map +1 -0
  18. package/dist/event-transforms/open-generative-ui-middleware.d.mts +60 -0
  19. package/dist/event-transforms/open-generative-ui-middleware.d.mts.map +1 -0
  20. package/dist/event-transforms/open-generative-ui-middleware.mjs +283 -0
  21. package/dist/event-transforms/open-generative-ui-middleware.mjs.map +1 -0
  22. package/dist/event-transforms/recorded-events.cjs +59 -0
  23. package/dist/event-transforms/recorded-events.cjs.map +1 -0
  24. package/dist/event-transforms/recorded-events.d.cts +23 -0
  25. package/dist/event-transforms/recorded-events.d.cts.map +1 -0
  26. package/dist/event-transforms/recorded-events.d.mts +23 -0
  27. package/dist/event-transforms/recorded-events.d.mts.map +1 -0
  28. package/dist/event-transforms/recorded-events.mjs +58 -0
  29. package/dist/event-transforms/recorded-events.mjs.map +1 -0
  30. package/dist/index.cjs +8 -0
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +3 -1
  33. package/dist/index.d.cts.map +1 -1
  34. package/dist/index.d.mts +3 -1
  35. package/dist/index.d.mts.map +1 -1
  36. package/dist/index.mjs +3 -1
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/index.umd.js +224 -1
  39. package/dist/index.umd.js.map +1 -1
  40. package/dist/package.cjs +1 -1
  41. package/dist/package.mjs +1 -1
  42. package/dist/types/message.d.cts +3 -3
  43. package/dist/types/message.d.cts.map +1 -1
  44. package/dist/types/message.d.mts +3 -3
  45. package/dist/types/message.d.mts.map +1 -1
  46. package/dist/utils/index.cjs +1 -0
  47. package/dist/utils/index.cjs.map +1 -1
  48. package/dist/utils/index.d.cts +1 -0
  49. package/dist/utils/index.d.cts.map +1 -1
  50. package/dist/utils/index.d.mts +1 -0
  51. package/dist/utils/index.d.mts.map +1 -1
  52. package/dist/utils/index.mjs +1 -0
  53. package/dist/utils/index.mjs.map +1 -1
  54. package/dist/utils/row-render-keys.cjs +206 -0
  55. package/dist/utils/row-render-keys.cjs.map +1 -0
  56. package/dist/utils/row-render-keys.d.cts +65 -0
  57. package/dist/utils/row-render-keys.d.cts.map +1 -0
  58. package/dist/utils/row-render-keys.d.mts +65 -0
  59. package/dist/utils/row-render-keys.d.mts.map +1 -0
  60. package/dist/utils/row-render-keys.mjs +201 -0
  61. package/dist/utils/row-render-keys.mjs.map +1 -0
  62. package/package.json +12 -1
  63. package/src/__tests__/root-entry-browser-safety.test.ts +7 -1
  64. package/src/attachments/content.test.ts +48 -0
  65. package/src/attachments/content.ts +16 -0
  66. package/src/attachments/index.ts +2 -0
  67. package/src/event-transforms/index.ts +5 -0
  68. package/src/event-transforms/open-generative-ui-middleware.ts +392 -0
  69. package/src/event-transforms/recorded-events.test.ts +166 -0
  70. package/src/event-transforms/recorded-events.ts +74 -0
  71. package/src/utils/__tests__/row-render-keys.test.ts +388 -0
  72. package/src/utils/index.ts +1 -0
  73. package/src/utils/row-render-keys.ts +263 -0
  74. 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
+ });
@@ -9,6 +9,7 @@ export * from "./inspector-visibility";
9
9
  export * from "./types";
10
10
  export * from "./random-id";
11
11
  export * from "./requests";
12
+ export * from "./row-render-keys";
12
13
 
13
14
  import * as PartialJSON from "partial-json";
14
15
 
@@ -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
- // `src/telemetry/index.ts` is a second entry so that the Node-only
6
- // telemetry client is reachable as `@copilotkit/shared/telemetry`. The
7
- // root entry deliberately does not re-export it (#4151).
8
- entry: ["src/index.ts", "src/telemetry/index.ts"],
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,