@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.
@@ -23,6 +23,7 @@ import { generateId } from '../helpers';
23
23
  import type {
24
24
  ConversationTree,
25
25
  MessageContent,
26
+ MessageMetadata,
26
27
  MessageRole,
27
28
  TreeMessage,
28
29
  } from '../types';
@@ -213,6 +214,22 @@ export interface ResponseAttachment {
213
214
  searchStoreId: string;
214
215
  }
215
216
 
217
+ /**
218
+ * Render-side attachment shape produced by `responsesToTree`. Stored under
219
+ * `metadata.attachments` on the user `TreeMessage` (chat-kit's `BaseMessage`
220
+ * /`TreeMessage` carry `metadata` as an opaque `Record<string, unknown>`).
221
+ *
222
+ * The shape is intentionally minimal — a human-readable `name` plus the
223
+ * optional ids needed to resolve a public URL for the file. Consumers
224
+ * using a different attachment shape can either skip this adapter or
225
+ * project this onto their own type at the read site.
226
+ */
227
+ export interface ResponseMessageAttachment {
228
+ name: string;
229
+ fileId?: string;
230
+ searchStoreId?: string;
231
+ }
232
+
216
233
  /**
217
234
  * One stored response from the OpenAI-style Responses API, as returned by
218
235
  * `GET /conversations/:id/responses`. `previous_response_id` chains
@@ -230,7 +247,7 @@ export interface StoredResponse {
230
247
  output: ResponseOutput[];
231
248
  request_input: Record<string, unknown>[];
232
249
  metadata?: Record<string, unknown> & {
233
- attachments?: ResponseAttachment[];
250
+ attachments?: string;
234
251
  };
235
252
  conversation?: { id: string };
236
253
  ancestor_ids?: string[];
@@ -252,11 +269,47 @@ export interface ResponseList {
252
269
  // Conversion: StoredResponse[] → ConversationTree
253
270
  // ---------------------------------------------------------------------------
254
271
 
272
+ /**
273
+ * Per-node metadata produced by `responsesToTree`. The adapter is the
274
+ * single point that knows about the Responses-API wire format, so it
275
+ * stamps the originating `responseId` onto every node it emits. Anything
276
+ * downstream that reads it (typically a host app linking a tree message
277
+ * back to its server-side response) does so via this narrower metadata
278
+ * shape — chat-kit core stays oblivious to the field and treats it as
279
+ * opaque metadata.
280
+ */
281
+ export type ResponseTreeMessageMetadata = MessageMetadata & {
282
+ responseId: string;
283
+ attachments?: ResponseMessageAttachment[];
284
+ };
285
+
286
+ /**
287
+ * Tree node emitted by `responsesToTree`. Structurally a `TreeMessage`,
288
+ * but with `metadata` narrowed to {@link ResponseTreeMessageMetadata} and
289
+ * promoted from optional to required: every node carries at least the
290
+ * originating `responseId`. Assignable to `TreeMessage` so the resulting
291
+ * tree can be passed directly to `useMessageHandler().setTree(...)`.
292
+ */
293
+ export type ResponseTreeMessage = Omit<TreeMessage, 'metadata'> & {
294
+ metadata: ResponseTreeMessageMetadata;
295
+ };
296
+
297
+ /**
298
+ * `ConversationTree` returned by `responsesToTree`. Same shape as the
299
+ * core `ConversationTree`, with every node typed as a
300
+ * {@link ResponseTreeMessage} so callers can read `metadata.responseId`
301
+ * without re-narrowing. Structurally assignable to `ConversationTree`.
302
+ */
303
+ export interface ResponseConversationTree extends ConversationTree {
304
+ nodes: Record<string, ResponseTreeMessage>;
305
+ }
306
+
255
307
  interface ResponseMessageNode {
256
308
  id: string;
257
309
  role: MessageRole;
258
310
  content: MessageContent[];
259
311
  createdAt: Date;
312
+ metadata: ResponseTreeMessageMetadata;
260
313
  }
261
314
 
262
315
  function extractMessageText(parts: ResponseOutputTextPart[]): string {
@@ -373,6 +426,38 @@ function extractInputText(rawContent: unknown): string {
373
426
  return text.trim();
374
427
  }
375
428
 
429
+ // Parse `resp.metadata.attachments` defensively. The Responses API
430
+ // serialises the value as a JSON-encoded string (transports that persist
431
+ // metadata flatten nested objects, so the array is stringified on the
432
+ // wire). Returns `null` when the payload is missing, empty, or malformed —
433
+ // callers treat that as "no attachments on this response". Malformed
434
+ // payloads are logged via `console.warn` so consumers can spot upstream
435
+ // serialisation issues.
436
+ function parseResponseAttachments(
437
+ resp: StoredResponse,
438
+ ): ResponseAttachment[] | null {
439
+ const raw = resp.metadata?.attachments;
440
+ if (!raw) return null;
441
+
442
+ let attachments: unknown;
443
+ try {
444
+ attachments = JSON.parse(raw);
445
+ } catch {
446
+ // eslint-disable-next-line no-console
447
+ console.warn(
448
+ '[chat-kit] Failed to parse metadata.attachments as JSON; skipping.',
449
+ raw,
450
+ );
451
+ return null;
452
+ }
453
+
454
+ if (!Array.isArray(attachments) || attachments.length === 0) {
455
+ return null;
456
+ }
457
+
458
+ return attachments as ResponseAttachment[];
459
+ }
460
+
376
461
  // Within a single response, the visible messages chain linearly: every user
377
462
  // input is followed by exactly one assistant turn (whose reasoning, tool,
378
463
  // and text outputs are merged into one node). Across responses, branching
@@ -380,21 +465,41 @@ function extractInputText(rawContent: unknown): string {
380
465
  // same parent and form sibling subtrees.
381
466
  function responseToMessageNodes(resp: StoredResponse): ResponseMessageNode[] {
382
467
  const nodes: ResponseMessageNode[] = [];
468
+ // Attachments are stored at the response level but logically belong to
469
+ // the user turn that started the response. We bind them to the first
470
+ // user `request_input` (mirroring the existing wire convention).
471
+ const responseAttachments = parseResponseAttachments(resp);
472
+ let userNodesPushed = 0;
383
473
  if (resp.request_input) {
384
474
  for (const [idx, input] of resp.request_input.entries()) {
385
475
  const role = (input as { role?: string }).role;
386
476
  const rawContent = (input as { content?: unknown }).content;
387
477
  const text = extractInputText(rawContent);
388
- if (role === 'user' && text) {
389
- nodes.push({
390
- id: `user-${resp.id}-${idx}`,
391
- role: 'user',
392
- content: [textPart(text)],
393
- createdAt: new Date(resp.created_at * 1000),
394
- });
478
+ const userContent = text || responseAttachments?.length;
479
+ if (role !== 'user' || !userContent) continue;
480
+
481
+ const metadata: ResponseTreeMessageMetadata = { responseId: resp.id };
482
+ if (responseAttachments && userNodesPushed === 0) {
483
+ metadata.attachments = responseAttachments.map(
484
+ (a): ResponseMessageAttachment => ({
485
+ name: a.fileName,
486
+ fileId: a.fileId,
487
+ searchStoreId: a.searchStoreId,
488
+ }),
489
+ );
395
490
  }
491
+
492
+ nodes.push({
493
+ id: `user-${resp.id}-${idx}`,
494
+ role: 'user',
495
+ content: [textPart(text)],
496
+ createdAt: new Date(resp.created_at * 1000),
497
+ metadata,
498
+ });
499
+ userNodesPushed += 1;
396
500
  }
397
501
  }
502
+
398
503
  const assistantParts = toAssistantParts(resp);
399
504
  if (assistantParts.length > 0) {
400
505
  nodes.push({
@@ -402,6 +507,7 @@ function responseToMessageNodes(resp: StoredResponse): ResponseMessageNode[] {
402
507
  role: 'assistant',
403
508
  content: assistantParts,
404
509
  createdAt: new Date((resp.completed_at ?? resp.created_at) * 1000),
510
+ metadata: { responseId: resp.id },
405
511
  });
406
512
  }
407
513
  return nodes;
@@ -417,15 +523,19 @@ function responseToMessageNodes(resp: StoredResponse): ResponseMessageNode[] {
417
523
  * last message immediately. Sibling responses (same `previous_response_id`)
418
524
  * surface as branches; the latest one wins as the active branch.
419
525
  *
420
- * Pair with `useMessageHandler().setTree(responsesToTree(responses))` when
421
- * you want to hydrate a handler.
526
+ * Every emitted node carries `metadata.responseId` pointing back to the
527
+ * originating `StoredResponse.id` (see {@link ResponseTreeMessage}). The
528
+ * return type is assignable to `ConversationTree`, so you can pipe it
529
+ * straight into `useMessageHandler().setTree(...)`.
422
530
  */
423
- export function responsesToTree(responses: StoredResponse[]): ConversationTree {
531
+ export function responsesToTree(
532
+ responses: StoredResponse[],
533
+ ): ResponseConversationTree {
424
534
  if (responses.length === 0) {
425
535
  return { nodes: {}, rootId: null };
426
536
  }
427
537
 
428
- const treeNodes: Record<string, TreeMessage> = {};
538
+ const treeNodes: Record<string, ResponseTreeMessage> = {};
429
539
  // For each response, the id of its last visible message — that's the
430
540
  // attachment point for any future child response that chains onto it.
431
541
  // Empty responses fall back to their parent's attachment point so the
@@ -443,6 +553,7 @@ export function responsesToTree(responses: StoredResponse[]): ConversationTree {
443
553
  content: node.content,
444
554
  status: 'completed',
445
555
  createdAt: node.createdAt,
556
+ metadata: node.metadata,
446
557
  };
447
558
  if (parentId !== null) {
448
559
  const parent = treeNodes[parentId];
@@ -118,6 +118,34 @@ describe('useMessageHandler', () => {
118
118
  handler.setCurrentUserMessage(' ');
119
119
  expect(() => handler.commitUserMessage()).toThrow('No message');
120
120
  });
121
+
122
+ it('attaches metadata when supplied and exposes it on the message', () => {
123
+ const handler = useMessageHandler();
124
+ handler.setCurrentUserMessage('Hello');
125
+ const msg = handler.commitUserMessage({
126
+ metadata: {
127
+ attachments: [
128
+ { name: 'report.pdf', fileId: 'f1', searchStoreId: 's1' },
129
+ ],
130
+ },
131
+ });
132
+
133
+ expect(msg.metadata).toEqual({
134
+ attachments: [
135
+ { name: 'report.pdf', fileId: 'f1', searchStoreId: 's1' },
136
+ ],
137
+ });
138
+ expect(handler.messages.value[0].metadata).toEqual(msg.metadata);
139
+ });
140
+
141
+ it('omits metadata on the returned message when none is supplied', () => {
142
+ const handler = useMessageHandler();
143
+ handler.setCurrentUserMessage('Hello');
144
+ const msg = handler.commitUserMessage();
145
+
146
+ expect('metadata' in msg).toBe(false);
147
+ expect('metadata' in handler.messages.value[0]).toBe(false);
148
+ });
121
149
  });
122
150
 
123
151
  describe('updateMessage', () => {
@@ -232,6 +260,61 @@ describe('useMessageHandler', () => {
232
260
  expect(getText(handler.messages.value[1].content)).toBe('Regenerated');
233
261
  });
234
262
 
263
+ it('preserves existing metadata when the update omits it', () => {
264
+ const handler = useMessageHandler();
265
+ handler.setCurrentUserMessage('Hello');
266
+ const msg = handler.commitUserMessage({
267
+ metadata: { attachments: [{ name: 'report.pdf' }] },
268
+ });
269
+
270
+ handler.updateMessage({
271
+ id: msg.id,
272
+ content: [text('Edited')],
273
+ role: 'user',
274
+ status: 'completed',
275
+ });
276
+
277
+ expect(handler.getMessageById(msg.id)?.metadata).toEqual({
278
+ attachments: [{ name: 'report.pdf' }],
279
+ });
280
+ });
281
+
282
+ it('overwrites metadata when explicitly supplied on update', () => {
283
+ const handler = useMessageHandler();
284
+ handler.setCurrentUserMessage('Hello');
285
+ const msg = handler.commitUserMessage({
286
+ metadata: { attachments: [{ name: 'old.pdf' }] },
287
+ });
288
+
289
+ handler.updateMessage({
290
+ id: msg.id,
291
+ content: [text('Hello')],
292
+ role: 'user',
293
+ status: 'completed',
294
+ metadata: { attachments: [{ name: 'new.pdf' }] },
295
+ });
296
+
297
+ expect(handler.getMessageById(msg.id)?.metadata).toEqual({
298
+ attachments: [{ name: 'new.pdf' }],
299
+ });
300
+ });
301
+
302
+ it('upserts a new message with metadata when supplied', () => {
303
+ const handler = useMessageHandler();
304
+ handler.updateMessage({
305
+ id: 'u-new',
306
+ content: [text('Hello')],
307
+ role: 'user',
308
+ status: 'completed',
309
+ metadata: { attachments: [{ name: 'fresh.pdf' }] },
310
+ });
311
+
312
+ const stored = handler.getMessageById('u-new');
313
+ expect(stored?.metadata).toEqual({
314
+ attachments: [{ name: 'fresh.pdf' }],
315
+ });
316
+ });
317
+
235
318
  it('does not truncate when updating the last message', () => {
236
319
  const handler = useMessageHandler();
237
320
  handler.setTree(
@@ -551,6 +634,137 @@ describe('useMessageHandler', () => {
551
634
  });
552
635
  });
553
636
 
637
+ describe('cutBranchAfter', () => {
638
+ it('removes every message that comes after the target on the active path', () => {
639
+ const handler = useMessageHandler();
640
+ handler.setTree(
641
+ makeTree(
642
+ {
643
+ u1: makeTreeMessage({ id: 'u1', content: [text('Hello')] }),
644
+ a1: makeTreeMessage({
645
+ id: 'a1',
646
+ parentId: 'u1',
647
+ role: 'assistant',
648
+ content: [text('Hi')],
649
+ }),
650
+ u2: makeTreeMessage({
651
+ id: 'u2',
652
+ parentId: 'a1',
653
+ content: [text('Follow-up')],
654
+ }),
655
+ a2: makeTreeMessage({
656
+ id: 'a2',
657
+ parentId: 'u2',
658
+ role: 'assistant',
659
+ content: [text('Answer')],
660
+ }),
661
+ },
662
+ 'u1',
663
+ ),
664
+ );
665
+
666
+ handler.cutBranchAfter('u2');
667
+
668
+ expect(handler.messages.value.map((m) => m.id)).toEqual([
669
+ 'u1',
670
+ 'a1',
671
+ 'u2',
672
+ ]);
673
+ expect(handler.getMessageById('a2')).toBeUndefined();
674
+ });
675
+
676
+ it('keeps the target message and makes it the new leaf', () => {
677
+ const handler = useMessageHandler();
678
+ handler.setTree(
679
+ makeTree(
680
+ {
681
+ u1: makeTreeMessage({ id: 'u1', content: [text('Hello')] }),
682
+ a1: makeTreeMessage({
683
+ id: 'a1',
684
+ parentId: 'u1',
685
+ role: 'assistant',
686
+ content: [text('Hi')],
687
+ }),
688
+ u2: makeTreeMessage({
689
+ id: 'u2',
690
+ parentId: 'a1',
691
+ content: [text('Follow-up')],
692
+ }),
693
+ },
694
+ 'u1',
695
+ ),
696
+ );
697
+
698
+ handler.cutBranchAfter('a1');
699
+
700
+ expect(handler.getMessageById('a1')).toBeDefined();
701
+ expect(handler.currentLeafId.value).toBe('a1');
702
+ });
703
+
704
+ it('drops sibling branches that share the cut ancestor', () => {
705
+ const handler = useMessageHandler();
706
+ handler.setTree(
707
+ makeTree(
708
+ {
709
+ u1: makeTreeMessage({ id: 'u1', content: [text('Hello')] }),
710
+ a1: makeTreeMessage({
711
+ id: 'a1',
712
+ parentId: 'u1',
713
+ role: 'assistant',
714
+ content: [text('Hi')],
715
+ }),
716
+ a2: makeTreeMessage({
717
+ id: 'a2',
718
+ parentId: 'u1',
719
+ role: 'assistant',
720
+ content: [text('Hey')],
721
+ }),
722
+ u2: makeTreeMessage({
723
+ id: 'u2',
724
+ parentId: 'a2',
725
+ content: [text('Follow-up')],
726
+ }),
727
+ },
728
+ 'u1',
729
+ ),
730
+ );
731
+
732
+ handler.cutBranchAfter('u1');
733
+
734
+ expect(handler.getMessageById('a1')).toBeUndefined();
735
+ expect(handler.getMessageById('a2')).toBeUndefined();
736
+ expect(handler.getMessageById('u2')).toBeUndefined();
737
+ expect(handler.messages.value.map((m) => m.id)).toEqual(['u1']);
738
+ });
739
+
740
+ it('is a no-op when the target has no descendants', () => {
741
+ const handler = useMessageHandler();
742
+ handler.setCurrentUserMessage('Hello');
743
+ const msg = handler.commitUserMessage();
744
+
745
+ handler.cutBranchAfter(msg.id);
746
+
747
+ expect(handler.messages.value).toHaveLength(1);
748
+ expect(handler.messages.value[0].id).toBe(msg.id);
749
+ });
750
+
751
+ it('is a no-op for a nonexistent id', () => {
752
+ const handler = useMessageHandler();
753
+ handler.setCurrentUserMessage('Hello');
754
+ handler.commitUserMessage();
755
+ handler.updateMessage({
756
+ id: 'a1',
757
+ content: [text('Reply')],
758
+ role: 'assistant',
759
+ status: 'completed',
760
+ });
761
+
762
+ handler.cutBranchAfter('nonexistent');
763
+
764
+ expect(handler.messages.value).toHaveLength(2);
765
+ });
766
+ });
767
+
554
768
  describe('currentLeafId', () => {
555
769
  it('is null when there are no messages', () => {
556
770
  const handler = useMessageHandler();
@@ -2,6 +2,7 @@ import {
2
2
  generateId,
3
3
  addNode as treeAddNode,
4
4
  createEmptyTree,
5
+ cutBranchAfter as treeCutBranchAfter,
5
6
  getLinearPath,
6
7
  navigate as treeNavigate,
7
8
  updateNode,
@@ -11,6 +12,7 @@ import type {
11
12
  BaseMessage,
12
13
  ConversationTree,
13
14
  MessageContent,
15
+ MessageMetadata,
14
16
  MessageRole,
15
17
  MessageStatus,
16
18
  TreeMessage,
@@ -36,7 +38,9 @@ export const useMessageHandler = () => {
36
38
  currentUserMessage.value = '';
37
39
  }
38
40
 
39
- function commitUserMessage(): BaseMessage {
41
+ function commitUserMessage(opts?: {
42
+ metadata?: MessageMetadata;
43
+ }): BaseMessage {
40
44
  const id = generateId();
41
45
  const text = currentUserMessage.value.trim();
42
46
  if (!text) throw new Error('No message');
@@ -54,18 +58,21 @@ export const useMessageHandler = () => {
54
58
  content,
55
59
  status: 'completed',
56
60
  createdAt: new Date(),
61
+ ...(opts?.metadata !== undefined ? { metadata: opts.metadata } : {}),
57
62
  };
58
63
 
59
64
  treeAddNode(tree, node);
60
65
  clearCurrentUserMessage();
61
66
 
62
- return {
67
+ const base: BaseMessage = {
63
68
  id: node.id,
64
69
  role: node.role,
65
70
  content: node.content,
66
71
  createdAt: node.createdAt,
67
72
  status: node.status,
68
73
  };
74
+ if (node.metadata !== undefined) base.metadata = node.metadata;
75
+ return base;
69
76
  }
70
77
 
71
78
  function updateMessage(message: {
@@ -73,6 +80,7 @@ export const useMessageHandler = () => {
73
80
  content: MessageContent[];
74
81
  role: MessageRole;
75
82
  status: MessageStatus;
83
+ metadata?: MessageMetadata;
76
84
  }) {
77
85
  const existing = tree.nodes[message.id];
78
86
 
@@ -81,6 +89,9 @@ export const useMessageHandler = () => {
81
89
  content: message.content,
82
90
  role: message.role,
83
91
  status: message.status,
92
+ ...(message.metadata !== undefined
93
+ ? { metadata: message.metadata }
94
+ : {}),
84
95
  });
85
96
  } else {
86
97
  const node: TreeMessage = {
@@ -92,6 +103,9 @@ export const useMessageHandler = () => {
92
103
  content: message.content,
93
104
  status: message.status,
94
105
  createdAt: new Date(),
106
+ ...(message.metadata !== undefined
107
+ ? { metadata: message.metadata }
108
+ : {}),
95
109
  };
96
110
  treeAddNode(tree, node);
97
111
  }
@@ -100,13 +114,15 @@ export const useMessageHandler = () => {
100
114
  function getMessageById(id: string): BaseMessage | undefined {
101
115
  const node = tree.nodes[id];
102
116
  if (!node) return undefined;
103
- return {
117
+ const base: BaseMessage = {
104
118
  id: node.id,
105
119
  role: node.role,
106
120
  content: node.content,
107
121
  createdAt: node.createdAt,
108
122
  status: node.status,
123
+ ...(node.metadata !== undefined ? { metadata: node.metadata } : {}),
109
124
  };
125
+ return base;
110
126
  }
111
127
 
112
128
  function updateMessageId(oldId: string, newId: string) {
@@ -135,6 +151,14 @@ export const useMessageHandler = () => {
135
151
  treeNavigate(tree, targetId);
136
152
  }
137
153
 
154
+ /**
155
+ * Drop every message that comes after `messageId` on every branch beneath
156
+ * it. The target message is kept and becomes the new leaf of its branch.
157
+ */
158
+ function cutBranchAfter(messageId: string) {
159
+ treeCutBranchAfter(tree, messageId);
160
+ }
161
+
138
162
  return {
139
163
  messages: messages as Ref<BaseMessage[]>,
140
164
  currentUserMessage: readonly(currentUserMessage),
@@ -148,5 +172,6 @@ export const useMessageHandler = () => {
148
172
  clearMessages,
149
173
  setTree,
150
174
  navigate,
175
+ cutBranchAfter,
151
176
  };
152
177
  };