@aleph-alpha/chat-kit 1.11.1 → 1.12.0

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.
@@ -7,6 +7,7 @@ import type {
7
7
  import {
8
8
  addNode,
9
9
  createEmptyTree,
10
+ cutBranchAfter,
10
11
  getLinearPath,
11
12
  navigate,
12
13
  updateNode,
@@ -193,6 +194,35 @@ describe('getLinearPath', () => {
193
194
  expect(u1Msg.status).toBe('completed');
194
195
  expect(u1Msg.createdAt).toEqual(new Date('2025-01-01'));
195
196
  });
197
+
198
+ it('forwards node metadata onto the BaseMessage projection', () => {
199
+ const tree = createEmptyTree();
200
+ addNode(
201
+ tree,
202
+ makeTreeMessage({
203
+ id: 'u1',
204
+ content: [text('Hello')],
205
+ metadata: {
206
+ attachments: [
207
+ { name: 'report.pdf', fileId: 'f1', searchStoreId: 's1' },
208
+ ],
209
+ },
210
+ }),
211
+ );
212
+
213
+ const path = getLinearPath(tree);
214
+ expect(path[0].metadata).toEqual({
215
+ attachments: [{ name: 'report.pdf', fileId: 'f1', searchStoreId: 's1' }],
216
+ });
217
+ });
218
+
219
+ it('omits metadata when the node has none', () => {
220
+ const tree = createEmptyTree();
221
+ addNode(tree, makeTreeMessage({ id: 'u1', content: [text('Hello')] }));
222
+
223
+ const path = getLinearPath(tree);
224
+ expect('metadata' in path[0]).toBe(false);
225
+ });
196
226
  });
197
227
 
198
228
  describe('addNode', () => {
@@ -287,6 +317,38 @@ describe('updateNode', () => {
287
317
  updateNode(tree, 'nonexistent', { content: [text('x')] });
288
318
  expect(Object.keys(tree.nodes).length).toBe(nodeCount);
289
319
  });
320
+
321
+ it('sets metadata when supplied', () => {
322
+ const tree = makeBranchingTree();
323
+ updateNode(tree, 'u1', {
324
+ metadata: {
325
+ attachments: [{ name: 'report.pdf', fileId: 'f1' }],
326
+ },
327
+ });
328
+ expect(tree.nodes.u1.metadata).toEqual({
329
+ attachments: [{ name: 'report.pdf', fileId: 'f1' }],
330
+ });
331
+ });
332
+
333
+ it('clears metadata when explicitly set to undefined', () => {
334
+ const tree = makeBranchingTree();
335
+ tree.nodes.u1.metadata = {
336
+ attachments: [{ name: 'old.pdf' }],
337
+ };
338
+ updateNode(tree, 'u1', { metadata: undefined });
339
+ expect(tree.nodes.u1.metadata).toBeUndefined();
340
+ });
341
+
342
+ it('leaves metadata untouched when the key is absent from updates', () => {
343
+ const tree = makeBranchingTree();
344
+ tree.nodes.u1.metadata = {
345
+ attachments: [{ name: 'keep.pdf' }],
346
+ };
347
+ updateNode(tree, 'u1', { content: [text('Updated')] });
348
+ expect(tree.nodes.u1.metadata).toEqual({
349
+ attachments: [{ name: 'keep.pdf' }],
350
+ });
351
+ });
290
352
  });
291
353
 
292
354
  describe('updateNodeId', () => {
@@ -385,6 +447,78 @@ describe('navigate', () => {
385
447
  });
386
448
  });
387
449
 
450
+ describe('cutBranchAfter', () => {
451
+ it('removes every descendant of the target node', () => {
452
+ const tree = makeBranchingTree();
453
+
454
+ cutBranchAfter(tree, 'u2');
455
+
456
+ expect(tree.nodes.a2).toBeUndefined();
457
+ expect(tree.nodes.a3).toBeUndefined();
458
+ });
459
+
460
+ it('keeps the target node and clears its children', () => {
461
+ const tree = makeBranchingTree();
462
+
463
+ cutBranchAfter(tree, 'u2');
464
+
465
+ expect(tree.nodes.u2).toBeDefined();
466
+ expect(tree.nodes.u2.childrenIds).toEqual([]);
467
+ expect(tree.nodes.u2.activeChildIndex).toBe(0);
468
+ });
469
+
470
+ it('makes the target node the leaf of the active path', () => {
471
+ const tree = makeBranchingTree();
472
+
473
+ cutBranchAfter(tree, 'u2');
474
+
475
+ const path = getLinearPath(tree);
476
+ expect(path.map((m) => m.id)).toEqual(['u1', 'a1', 'u2']);
477
+ });
478
+
479
+ it('removes the entire subtree, including grandchildren', () => {
480
+ const tree = makeBranchingTree();
481
+
482
+ cutBranchAfter(tree, 'u1');
483
+
484
+ expect(Object.keys(tree.nodes)).toEqual(['u1']);
485
+ expect(tree.nodes.u1.childrenIds).toEqual([]);
486
+ });
487
+
488
+ it('only cuts descendants — sibling branches are untouched', () => {
489
+ const tree = makeBranchingTree();
490
+
491
+ cutBranchAfter(tree, 'a1');
492
+
493
+ expect(tree.nodes.u2).toBeUndefined();
494
+ expect(tree.nodes.a2).toBeUndefined();
495
+ expect(tree.nodes.a3).toBeUndefined();
496
+
497
+ expect(tree.nodes.a1b).toBeDefined();
498
+ expect(tree.nodes.u3).toBeDefined();
499
+ expect(tree.nodes.u1.childrenIds).toEqual(['a1', 'a1b']);
500
+ });
501
+
502
+ it('is a no-op when the node has no children', () => {
503
+ const tree = makeBranchingTree();
504
+ const nodeCount = Object.keys(tree.nodes).length;
505
+
506
+ cutBranchAfter(tree, 'a2');
507
+
508
+ expect(Object.keys(tree.nodes).length).toBe(nodeCount);
509
+ expect(tree.nodes.a2.childrenIds).toEqual([]);
510
+ });
511
+
512
+ it('is a no-op for a nonexistent id', () => {
513
+ const tree = makeBranchingTree();
514
+ const before = Object.keys(tree.nodes).sort();
515
+
516
+ cutBranchAfter(tree, 'nonexistent');
517
+
518
+ expect(Object.keys(tree.nodes).sort()).toEqual(before);
519
+ });
520
+ });
521
+
388
522
  describe('integration: full branching scenario', () => {
389
523
  it('simulates send → regenerate → navigate workflow', () => {
390
524
  const tree = createEmptyTree();
@@ -25,6 +25,7 @@ export function getLinearPath(tree: ConversationTree): BaseMessage[] {
25
25
  content: node.content,
26
26
  createdAt: node.createdAt,
27
27
  status: node.status,
28
+ ...(node.metadata !== undefined ? { metadata: node.metadata } : {}),
28
29
  };
29
30
 
30
31
  if (node.parentId !== null) {
@@ -61,7 +62,9 @@ export function addNode(tree: ConversationTree, node: TreeMessage): void {
61
62
  export function updateNode(
62
63
  tree: ConversationTree,
63
64
  id: string,
64
- updates: Partial<Pick<TreeMessage, 'content' | 'status' | 'role'>>,
65
+ updates: Partial<
66
+ Pick<TreeMessage, 'content' | 'status' | 'role' | 'metadata'>
67
+ >,
65
68
  ): void {
66
69
  const node = tree.nodes[id];
67
70
  if (!node) return;
@@ -69,6 +72,9 @@ export function updateNode(
69
72
  if (updates.content !== undefined) node.content = updates.content;
70
73
  if (updates.status !== undefined) node.status = updates.status;
71
74
  if (updates.role !== undefined) node.role = updates.role;
75
+ // `metadata` is intentionally allowed through with an `undefined` value so
76
+ // callers can clear it; the call site checks for `'metadata' in updates`.
77
+ if ('metadata' in updates) node.metadata = updates.metadata;
72
78
  }
73
79
 
74
80
  export function updateNodeId(
@@ -99,6 +105,31 @@ export function updateNodeId(
99
105
  if (tree.rootId === oldId) tree.rootId = newId;
100
106
  }
101
107
 
108
+ /**
109
+ * Remove every descendant of the node with the given id, keeping the node
110
+ * itself intact. The node's `childrenIds` is cleared and `activeChildIndex`
111
+ * is reset to 0, which makes the node the new leaf of its branch.
112
+ *
113
+ * Useful when editing a message and discarding the conversation that was
114
+ * generated after it.
115
+ */
116
+ export function cutBranchAfter(tree: ConversationTree, id: string): void {
117
+ const node = tree.nodes[id];
118
+ if (!node) return;
119
+
120
+ const stack: string[] = [...node.childrenIds];
121
+ while (stack.length > 0) {
122
+ const currentId = stack.pop() as string;
123
+ const current = tree.nodes[currentId];
124
+ if (!current) continue;
125
+ stack.push(...current.childrenIds);
126
+ delete tree.nodes[currentId];
127
+ }
128
+
129
+ node.childrenIds = [];
130
+ node.activeChildIndex = 0;
131
+ }
132
+
102
133
  /**
103
134
  * Navigate to a different branch by updating the parent's activeChildIndex
104
135
  * to point at the target sibling.
package/src/index.ts CHANGED
@@ -107,6 +107,7 @@ export type {
107
107
  ConversationTree,
108
108
  MessageContent,
109
109
  MessageContentStatus,
110
+ MessageMetadata,
110
111
  MessageReasoningContent,
111
112
  MessageRole,
112
113
  MessageStatus,
package/src/types.ts CHANGED
@@ -68,6 +68,19 @@ export type MessageContent =
68
68
  | MessageToolContent
69
69
  | MessageReasoningContent;
70
70
 
71
+ /**
72
+ * Open-ended metadata bag carried alongside a message. chat-kit is
73
+ * intentionally agnostic about its contents — adapter authors
74
+ * (`responsesToTree`, …) and message handlers preserve the value verbatim
75
+ * through the tree → linear path projection, but never inspect it.
76
+ *
77
+ * Consumers attach their own well-known keys (attachments, citations,
78
+ * ratings, …) and define their own typed projections at the read site
79
+ * rather than extending this base type. That keeps the handler API stable
80
+ * even as the shape of what gets stored on a message evolves.
81
+ */
82
+ export type MessageMetadata = Record<string, unknown>;
83
+
71
84
  export interface BaseMessage {
72
85
  id: string;
73
86
  role: MessageRole;
@@ -76,6 +89,7 @@ export interface BaseMessage {
76
89
  status: MessageStatus;
77
90
  siblingIds?: string[];
78
91
  siblingIndex?: number;
92
+ metadata?: MessageMetadata;
79
93
  }
80
94
 
81
95
  export interface TreeMessage {
@@ -87,6 +101,7 @@ export interface TreeMessage {
87
101
  content: MessageContent[];
88
102
  status: MessageStatus;
89
103
  createdAt: Date;
104
+ metadata?: MessageMetadata;
90
105
  }
91
106
 
92
107
  export interface ConversationTree {