@aleph-alpha/chat-kit 3.1.3 → 3.2.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.
Files changed (30) hide show
  1. package/dist/{CkInput.vue_vue_type_script_setup_true_lang-LPrW7CSQ.js → CkInput.vue_vue_type_script_setup_true_lang-B-4xnH2c.js} +1 -1
  2. package/dist/adapters/responses-api.d.ts.map +1 -1
  3. package/dist/adapters/responses-api.js +63 -37
  4. package/dist/components/index.js +1 -1
  5. package/dist/composables/fileUpload/types.d.ts +3 -0
  6. package/dist/composables/fileUpload/types.d.ts.map +1 -1
  7. package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
  8. package/dist/composables/fileUpload/useFileUploadWrapper.d.ts.map +1 -1
  9. package/dist/composables/index.js +1 -1
  10. package/dist/handlers/index.d.ts +1 -0
  11. package/dist/handlers/index.d.ts.map +1 -1
  12. package/dist/handlers/index.js +3 -1
  13. package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
  14. package/dist/helpers/unified/remarkNormalizeMath.d.ts +24 -0
  15. package/dist/helpers/unified/remarkNormalizeMath.d.ts.map +1 -0
  16. package/dist/{index-DVxCNAs3.js → index-DAmWGYNH.js} +37 -5
  17. package/dist/index.js +3 -3
  18. package/dist/{useChatKitLabels-Ctz1dHpx.js → useChatKitLabels-D5gNv8se.js} +97 -1
  19. package/package.json +4 -3
  20. package/src/adapters/responses-api.spec.ts +156 -0
  21. package/src/adapters/responses-api.ts +125 -58
  22. package/src/composables/fileUpload/types.ts +6 -0
  23. package/src/composables/fileUpload/useFileUpload.ts +7 -1
  24. package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +74 -24
  25. package/src/composables/fileUpload/useFileUploadWrapper.ts +44 -4
  26. package/src/handlers/index.ts +4 -0
  27. package/src/handlers/useMarkdownParser.spec.ts +34 -0
  28. package/src/handlers/useMarkdownParser.ts +10 -1
  29. package/src/helpers/unified/remarkNormalizeMath.spec.ts +84 -0
  30. package/src/helpers/unified/remarkNormalizeMath.ts +134 -0
@@ -441,6 +441,162 @@ describe('responsesToTree', () => {
441
441
  }
442
442
  });
443
443
 
444
+ it('merges a tool-loop continuation (response with no user input) into the same assistant turn', () => {
445
+ // One user turn that runs a multi-step tool loop is persisted as several
446
+ // chained responses: the first carries the user input, each follow-up
447
+ // carries a tool result (no user input). They are one assistant turn and
448
+ // must render as one message with a single thinking trail (SPHER-5147).
449
+ const tree = responsesToTree([
450
+ makeResponse({
451
+ id: 'r1',
452
+ request_input: [userInput('Compare Tokyo and Delhi populations')],
453
+ output: [
454
+ reasoningOutput('rs-1', 'search for Tokyo'),
455
+ toolOutput({ id: 'fc-1', type: 'function_call', name: 'search' }),
456
+ ],
457
+ }),
458
+ makeResponse({
459
+ id: 'r2',
460
+ previous_response_id: 'r1',
461
+ // Tool-result continuation — no user input on the wire.
462
+ request_input: [],
463
+ completed_at: 1_777_456_999,
464
+ output: [
465
+ reasoningOutput('rs-2', 'now search for Delhi'),
466
+ toolOutput({ id: 'fc-2', type: 'function_call', name: 'search' }),
467
+ assistantOutput('msg-final', 'Tokyo is larger'),
468
+ ],
469
+ }),
470
+ ]);
471
+
472
+ // Exactly one user node and one (merged) assistant node — not two
473
+ // assistant bubbles. r1 has no message output, so its assistant node id
474
+ // falls back to `assistant-r1`.
475
+ expect(Object.keys(tree.nodes)).toHaveLength(2);
476
+ expect(tree.nodes['msg-final']).toBeUndefined();
477
+
478
+ const assistant = tree.nodes['assistant-r1']!;
479
+ expect(assistant.role).toBe('assistant');
480
+ expect(tree.nodes['user-r1-0']!.childrenIds).toEqual(['assistant-r1']);
481
+ // Reasoning + tool from both round-trips, in wire order, then final text.
482
+ expect(assistant.content.map((c) => c.type)).toEqual([
483
+ 'reasoning',
484
+ 'tool',
485
+ 'reasoning',
486
+ 'tool',
487
+ 'text',
488
+ ]);
489
+ // responseId stays the originating round-trip (the one that opened the
490
+ // turn), preserving the documented "originating StoredResponse.id" stamp.
491
+ expect(assistant.metadata.responseId).toBe('r1');
492
+ // createdAt advances to the latest folded round-trip so it is never
493
+ // stamped earlier than content it now holds.
494
+ expect(assistant.createdAt).toEqual(new Date(1_777_456_999 * 1000));
495
+ });
496
+
497
+ it('merges a tool-loop continuation in a later turn, leaving earlier turns intact', () => {
498
+ // The fix must work for any turn, not just the first: a simple turn,
499
+ // followed by a second turn that runs a tool loop. The second turn's
500
+ // continuation folds into its own assistant node; turn one is untouched.
501
+ const tree = responsesToTree([
502
+ makeResponse({
503
+ id: 'r1',
504
+ request_input: [userInput('Hi')],
505
+ output: [assistantOutput('msg-a1', 'Hello!')],
506
+ }),
507
+ makeResponse({
508
+ id: 'r2',
509
+ previous_response_id: 'r1',
510
+ request_input: [userInput('Now compare Tokyo and Delhi')],
511
+ output: [
512
+ reasoningOutput('rs-2', 'search Tokyo'),
513
+ toolOutput({ id: 'fc-2', type: 'function_call', name: 'search' }),
514
+ ],
515
+ }),
516
+ makeResponse({
517
+ id: 'r3',
518
+ previous_response_id: 'r2',
519
+ request_input: [], // tool-result continuation of turn two
520
+ output: [
521
+ reasoningOutput('rs-3', 'search Delhi'),
522
+ toolOutput({ id: 'fc-3', type: 'function_call', name: 'search' }),
523
+ assistantOutput('msg-a3', 'Tokyo is larger'),
524
+ ],
525
+ }),
526
+ ]);
527
+
528
+ // Turn one: untouched, single reply.
529
+ const a1 = tree.nodes['msg-a1']!;
530
+ expect(getText(a1.content)).toBe('Hello!');
531
+ expect(a1.parentId).toBe('user-r1-0');
532
+
533
+ // Turn two: its continuation (r3) merged into turn two's assistant node
534
+ // (id falls back to `assistant-r2` since r2 has no message output).
535
+ expect(tree.nodes['msg-a3']).toBeUndefined();
536
+ const a2 = tree.nodes['assistant-r2']!;
537
+ expect(a2.parentId).toBe('user-r2-0');
538
+ expect(tree.nodes['user-r2-0']!.parentId).toBe('msg-a1');
539
+ expect(a2.content.map((c) => c.type)).toEqual([
540
+ 'reasoning',
541
+ 'tool',
542
+ 'reasoning',
543
+ 'tool',
544
+ 'text',
545
+ ]);
546
+ // Four message nodes total: two user, two assistant (one per turn).
547
+ expect(Object.keys(tree.nodes)).toHaveLength(4);
548
+ });
549
+
550
+ it('still starts a new assistant turn when a follow-up response carries a user input', () => {
551
+ // Guard against over-merging: a genuine next turn (user input present)
552
+ // must remain its own message, not fold into the previous answer.
553
+ const tree = responsesToTree([
554
+ makeResponse({
555
+ id: 'r1',
556
+ request_input: [userInput('First')],
557
+ output: [assistantOutput('msg-a1', 'A1')],
558
+ }),
559
+ makeResponse({
560
+ id: 'r2',
561
+ previous_response_id: 'r1',
562
+ request_input: [userInput('Second')],
563
+ output: [assistantOutput('msg-a2', 'A2')],
564
+ }),
565
+ ]);
566
+
567
+ expect(tree.nodes['user-r2-0']!.parentId).toBe('msg-a1');
568
+ expect(tree.nodes['msg-a2']!.parentId).toBe('user-r2-0');
569
+ expect(tree.nodes['msg-a2']!.metadata.responseId).toBe('r2');
570
+ });
571
+
572
+ it('does not merge a response that carries a user input even when its content is empty', () => {
573
+ // A user-role input with empty content produces no user node, so a
574
+ // node-shape-only check would wrongly treat this as a continuation. The
575
+ // merge keys off `request_input` directly, so the assistant reply stays
576
+ // its own node rather than folding into the previous turn.
577
+ const tree = responsesToTree([
578
+ makeResponse({
579
+ id: 'r1',
580
+ request_input: [userInput('First')],
581
+ output: [assistantOutput('msg-a1', 'A1')],
582
+ }),
583
+ makeResponse({
584
+ id: 'r2',
585
+ previous_response_id: 'r1',
586
+ request_input: [{ role: 'user', content: '' }],
587
+ output: [assistantOutput('msg-a2', 'A2')],
588
+ }),
589
+ ]);
590
+
591
+ const a1 = tree.nodes['msg-a1']!;
592
+ const a2 = tree.nodes['msg-a2']!;
593
+ expect(a2).toBeDefined();
594
+ expect(getText(a2.content)).toBe('A2');
595
+ // a2 is its own node, not merged into a1's content.
596
+ expect(getText(a1.content)).toBe('A1');
597
+ expect(a2.parentId).toBe('msg-a1');
598
+ });
599
+
444
600
  it('chains the next response under a reasoning-only assistant turn', () => {
445
601
  const tree = responsesToTree([
446
602
  makeResponse({
@@ -513,6 +513,124 @@ function responseToMessageNodes(resp: StoredResponse): ResponseMessageNode[] {
513
513
  return nodes;
514
514
  }
515
515
 
516
+ // Insert a node and wire it under `parentId`; latest child wins as the active
517
+ // branch (surfaces the most recent sibling branch by default).
518
+ function appendNode(
519
+ treeNodes: Record<string, ResponseTreeMessage>,
520
+ node: ResponseMessageNode,
521
+ parentId: string | null,
522
+ ): void {
523
+ treeNodes[node.id] = {
524
+ id: node.id,
525
+ parentId,
526
+ childrenIds: [],
527
+ activeChildIndex: 0,
528
+ role: node.role,
529
+ content: node.content,
530
+ status: 'completed',
531
+ createdAt: node.createdAt,
532
+ metadata: node.metadata,
533
+ };
534
+ if (parentId === null) return;
535
+ const parent = treeNodes[parentId];
536
+ if (!parent) return;
537
+ parent.childrenIds.push(node.id);
538
+ parent.activeChildIndex = parent.childrenIds.length - 1;
539
+ }
540
+
541
+ // A user-role input on the wire delimits a turn. Read from `request_input`
542
+ // directly: `responseToMessageNodes` drops empty user inputs, so a node-shape
543
+ // check could misread an empty new turn as a continuation.
544
+ function responseHasUserInput(resp: StoredResponse): boolean {
545
+ return (resp.request_input ?? []).some(
546
+ (input) => (input as { role?: string }).role === 'user',
547
+ );
548
+ }
549
+
550
+ // True when `resp` continues the assistant turn anchored at `parentNode`: a
551
+ // tool-loop round-trip (assistant-only output, no new user input) rather than a
552
+ // new turn. The Responses API stores each round-trip as its own response, but
553
+ // they are one turn. New turns and regeneration branches carry a user input.
554
+ function isAssistantContinuation(
555
+ parentNode: ResponseTreeMessage,
556
+ messageNodes: ResponseMessageNode[],
557
+ resp: StoredResponse,
558
+ ): boolean {
559
+ return (
560
+ parentNode.role === 'assistant' &&
561
+ messageNodes.length > 0 &&
562
+ messageNodes.every((node) => node.role === 'assistant') &&
563
+ !responseHasUserInput(resp)
564
+ );
565
+ }
566
+
567
+ // Fold a continuation round-trip's output onto the turn it extends, so the turn
568
+ // stays one message with one "thinking" trail — matching the live path;
569
+ // otherwise the trail splits on reload (SPHER-5147). `responseId` is left as the
570
+ // originating response, per ResponseTreeMessageMetadata's contract.
571
+ function foldContinuation(
572
+ parentNode: ResponseTreeMessage,
573
+ messageNodes: ResponseMessageNode[],
574
+ ): void {
575
+ for (const node of messageNodes) {
576
+ parentNode.content.push(...node.content);
577
+ // Advance the turn's timestamp as later round-trips fold in, so it is
578
+ // never stamped earlier than content it now holds (consistent with the
579
+ // per-response `completed_at` stamp used elsewhere). Only moves forward.
580
+ if (node.createdAt.getTime() > parentNode.createdAt.getTime()) {
581
+ parentNode.createdAt = node.createdAt;
582
+ }
583
+ }
584
+ }
585
+
586
+ // Mutable accumulators threaded through the single build pass.
587
+ interface TreeBuildState {
588
+ treeNodes: Record<string, ResponseTreeMessage>;
589
+ // For each response, the id of its last visible message — the attachment
590
+ // point for any future child response that chains onto it. Empty responses
591
+ // fall back to their parent's attachment point so the chain stays connected.
592
+ tailMessageByResponse: Map<string, string | null>;
593
+ rootMessageId: string | null;
594
+ }
595
+
596
+ // Wire one response into the tree, extending `state` in place. Tool-loop
597
+ // continuations fold onto the turn they extend; everything else appends under
598
+ // the chain parent resolved from `previous_response_id`.
599
+ function linkResponse(state: TreeBuildState, resp: StoredResponse): void {
600
+ const { treeNodes, tailMessageByResponse } = state;
601
+ const parentResponseId =
602
+ resp.previous_response_id &&
603
+ tailMessageByResponse.has(resp.previous_response_id)
604
+ ? resp.previous_response_id
605
+ : null;
606
+ let chainParent =
607
+ parentResponseId !== null
608
+ ? (tailMessageByResponse.get(parentResponseId) ?? null)
609
+ : null;
610
+
611
+ const messageNodes = responseToMessageNodes(resp);
612
+
613
+ const parentNode = chainParent !== null ? treeNodes[chainParent] : null;
614
+ if (parentNode && isAssistantContinuation(parentNode, messageNodes, resp)) {
615
+ foldContinuation(parentNode, messageNodes);
616
+ tailMessageByResponse.set(resp.id, parentNode.id);
617
+ return;
618
+ }
619
+
620
+ let firstId: string | null = null;
621
+ for (const node of messageNodes) {
622
+ appendNode(treeNodes, node, chainParent);
623
+ if (firstId === null) firstId = node.id;
624
+ chainParent = node.id;
625
+ }
626
+
627
+ if (state.rootMessageId === null && firstId !== null) {
628
+ state.rootMessageId = firstId;
629
+ }
630
+
631
+ tailMessageByResponse.set(resp.id, chainParent);
632
+ }
633
+
516
634
  /**
517
635
  * Convert an OpenAI Responses API list (already ordered by `created_at`
518
636
  * ascending) into a chat-kit `ConversationTree`.
@@ -535,66 +653,15 @@ export function responsesToTree(
535
653
  return { nodes: {}, rootId: null };
536
654
  }
537
655
 
538
- const treeNodes: Record<string, ResponseTreeMessage> = {};
539
- // For each response, the id of its last visible message — that's the
540
- // attachment point for any future child response that chains onto it.
541
- // Empty responses fall back to their parent's attachment point so the
542
- // chain stays connected.
543
- const tailMessageByResponse = new Map<string, string | null>();
544
- let rootMessageId: string | null = null;
545
-
546
- function appendNode(node: ResponseMessageNode, parentId: string | null) {
547
- treeNodes[node.id] = {
548
- id: node.id,
549
- parentId,
550
- childrenIds: [],
551
- activeChildIndex: 0,
552
- role: node.role,
553
- content: node.content,
554
- status: 'completed',
555
- createdAt: node.createdAt,
556
- metadata: node.metadata,
557
- };
558
- if (parentId !== null) {
559
- const parent = treeNodes[parentId];
560
- if (parent) {
561
- parent.childrenIds.push(node.id);
562
- // Latest insertion wins as the active branch — within a single
563
- // response there's only one push, so this is a no-op there; for
564
- // sibling branches it surfaces the most recent one by default.
565
- parent.activeChildIndex = parent.childrenIds.length - 1;
566
- }
567
- }
568
- }
656
+ const state: TreeBuildState = {
657
+ treeNodes: {},
658
+ tailMessageByResponse: new Map<string, string | null>(),
659
+ rootMessageId: null,
660
+ };
569
661
 
570
662
  for (const resp of responses) {
571
- const parentResponseId =
572
- resp.previous_response_id &&
573
- tailMessageByResponse.has(resp.previous_response_id)
574
- ? resp.previous_response_id
575
- : null;
576
- let chainParent =
577
- parentResponseId !== null
578
- ? (tailMessageByResponse.get(parentResponseId) ?? null)
579
- : null;
580
-
581
- const messageNodes = responseToMessageNodes(resp);
582
- let firstId: string | null = null;
583
- for (const node of messageNodes) {
584
- appendNode(node, chainParent);
585
- if (firstId === null) firstId = node.id;
586
- chainParent = node.id;
587
- }
588
-
589
- if (rootMessageId === null && firstId !== null) {
590
- rootMessageId = firstId;
591
- }
592
-
593
- // Track the attachment point for descendants. If the response had no
594
- // visible messages, pass through the parent's tail so children of this
595
- // response still land in the right place.
596
- tailMessageByResponse.set(resp.id, chainParent);
663
+ linkResponse(state, resp);
597
664
  }
598
665
 
599
- return { nodes: treeNodes, rootId: rootMessageId };
666
+ return { nodes: state.treeNodes, rootId: state.rootMessageId };
600
667
  }
@@ -34,6 +34,8 @@ export interface EndpointResolver {
34
34
  files: (storeId: string) => string;
35
35
  file: (storeId: string, fileId: string) => string;
36
36
  filePublicUrl: (storeId: string, fileId: string) => string;
37
+ workflows: (storeId: string) => string;
38
+ createWorkflow: (storeId: string) => string;
37
39
  workflowRun: (storeId: string, workflowId: string) => string;
38
40
  workflowRunEventStream: (
39
41
  storeId: string,
@@ -47,6 +49,9 @@ export const defaultEndpoints: EndpointResolver = {
47
49
  file: (storeId, fileId) => `search-stores/${storeId}/files/${fileId}`,
48
50
  filePublicUrl: (storeId, fileId) =>
49
51
  `search-stores/${storeId}/files/${fileId}/public-urls?ttl=1h`,
52
+ workflows: (storeId) =>
53
+ `search-stores/${storeId}/workflows?limit=20&offset=0&created_after=&created_before=`,
54
+ createWorkflow: (storeId) => `search-stores/${storeId}/workflows`,
50
55
  workflowRun: (storeId, workflowId) =>
51
56
  `search-stores/${storeId}/workflows/${workflowId}/runs`,
52
57
  workflowRunEventStream: (storeId, workflowId, runId) =>
@@ -410,6 +415,7 @@ export interface UseFileUploadReturn {
410
415
  updateFileMetadata: (id: string, meta: Record<string, unknown>) => void;
411
416
  clearState: () => void;
412
417
  setStoreId: (id: string) => void;
418
+ setWorkflowId: (id: string) => void;
413
419
  getPublicUrl: (fileId: string) => Promise<string>;
414
420
 
415
421
  scopeBy: (metadataKey: string, value: string) => ScopedFileView;
@@ -469,8 +469,9 @@ function deriveStateKey(
469
469
  export function useFileUpload(
470
470
  options: UseFileUploadOptions,
471
471
  ): UseFileUploadReturn {
472
- const { client, workflowId } = options;
472
+ const { client } = options;
473
473
  let storeId = options.storeId ?? '';
474
+ let workflowId = options.workflowId;
474
475
  const endpoints: EndpointResolver = options.endpoints ?? defaultEndpoints;
475
476
  const retryPolicy: RetryPolicy = options.retryPolicy ?? DEFAULT_RETRY_POLICY;
476
477
  const constraints: FileConstraints = {
@@ -1257,6 +1258,10 @@ export function useFileUpload(
1257
1258
  storeId = id;
1258
1259
  }
1259
1260
 
1261
+ function setWorkflowId(id: string) {
1262
+ workflowId = id;
1263
+ }
1264
+
1260
1265
  async function getPublicUrl(fileId: string): Promise<string> {
1261
1266
  const res = await client.post<{ url: string; expires_at: string }>(
1262
1267
  endpoints.filePublicUrl(storeId, fileId),
@@ -1277,6 +1282,7 @@ export function useFileUpload(
1277
1282
  updateFileMetadata,
1278
1283
  clearState,
1279
1284
  setStoreId,
1285
+ setWorkflowId,
1280
1286
  getPublicUrl,
1281
1287
 
1282
1288
  scopeBy,
@@ -1,6 +1,34 @@
1
+ import { flushPromises } from '@vue/test-utils';
2
+ import { ref } from 'vue';
1
3
  import { createDocumentUploadProvider } from './useFileUploadWrapper';
2
4
  import type { DocumentUploadConfig } from './types';
3
5
 
6
+ const uploadMock = vi.hoisted(() => ({
7
+ upload: vi.fn().mockResolvedValue({ results: [] }),
8
+ }));
9
+
10
+ vi.mock('./useFileUpload', () => ({
11
+ useFileUpload: vi.fn((options) => {
12
+ const scopedFiles = ref([]);
13
+ return {
14
+ files: ref(new Map()),
15
+ getSupportedFileTypes: () => ({
16
+ maxFilesPerUpload: options.maxFilesPerUpload ?? 20,
17
+ allowedMimeTypes: ['application/pdf'],
18
+ allowedExtensions: ['.pdf'],
19
+ maxFileSizeBytes: 100 * 1024 * 1024,
20
+ }),
21
+ scopeBy: () => ({ files: scopedFiles }),
22
+ upload: uploadMock.upload,
23
+ runWorkflow: vi.fn().mockResolvedValue({ close: vi.fn() }),
24
+ setStoreId: vi.fn(),
25
+ setWorkflowId: vi.fn(),
26
+ updateFileMetadata: vi.fn(),
27
+ removeFile: vi.fn(),
28
+ };
29
+ }),
30
+ }));
31
+
4
32
  function createFile(name: string, size = 100): File {
5
33
  return new File(['x'.repeat(size)], name, { type: 'application/pdf' });
6
34
  }
@@ -31,6 +59,7 @@ function buildConfig(
31
59
  return {
32
60
  baseUrl: 'https://test.example.com',
33
61
  workflowId: 'wf-1',
62
+ persist: false,
34
63
  searchStoreConfig: {
35
64
  models: [
36
65
  {
@@ -48,16 +77,35 @@ function buildConfig(
48
77
  };
49
78
  }
50
79
 
80
+ async function settleAsyncUploads() {
81
+ await flushPromises();
82
+ }
83
+
51
84
  describe('useFileUploadWrapper handleFilesSelected', () => {
52
85
  let fetchMock: ReturnType<typeof vi.fn>;
53
86
 
54
87
  beforeEach(() => {
88
+ uploadMock.upload.mockClear();
89
+ uploadMock.upload.mockResolvedValue({ results: [] });
90
+
55
91
  fetchMock = vi.fn();
56
92
  vi.stubGlobal('fetch', fetchMock);
57
93
 
58
94
  fetchMock.mockImplementation((url: string, init?: RequestInit) => {
59
95
  const method = init?.method ?? 'GET';
60
96
 
97
+ if (method === 'GET' && url.includes('/workflows')) {
98
+ return createMockFetchResponse({
99
+ items: [
100
+ {
101
+ id: 'workflow-api-1',
102
+ name: 'wf-1',
103
+ created_at: '2024-01-01T00:00:00Z',
104
+ },
105
+ ],
106
+ total: 1,
107
+ });
108
+ }
61
109
  if (method === 'GET' && url.includes('search-stores')) {
62
110
  return createMockFetchResponse({
63
111
  items: [
@@ -70,27 +118,12 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
70
118
  total: 1,
71
119
  });
72
120
  }
73
- if (method === 'POST' && url.includes('/files')) {
74
- return createMockFetchResponse([{ id: 'remote-1' }]);
75
- }
76
- if (method === 'POST' && url.includes('/workflows')) {
77
- return createMockFetchResponse({ id: 'run-1' });
78
- }
79
- if (url.includes('/event-stream')) {
80
- return createMockFetchResponse(null, 200).then((res) => ({
81
- ...res,
82
- body: new ReadableStream({
83
- start(controller) {
84
- controller.close();
85
- },
86
- }),
87
- }));
88
- }
89
121
  return createMockFetchResponse({});
90
122
  });
91
123
  });
92
124
 
93
- afterEach(() => {
125
+ afterEach(async () => {
126
+ await settleAsyncUploads();
94
127
  vi.unstubAllGlobals();
95
128
  });
96
129
 
@@ -115,26 +148,31 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
115
148
  const { handleFilesSelected } = provider.useDocumentUpload('conv-1');
116
149
 
117
150
  const onFilesExceeded = vi.fn();
118
- const event = makeInputEvent([createFile('a.pdf'), createFile('b.pdf')]);
151
+ const files = [createFile('a.pdf'), createFile('b.pdf')];
152
+ const event = makeInputEvent(files);
119
153
 
120
154
  handleFilesSelected(event, { onFilesExceeded });
121
155
 
122
156
  expect(onFilesExceeded).not.toHaveBeenCalled();
157
+ await settleAsyncUploads();
158
+ expect(uploadMock.upload).toHaveBeenCalledOnce();
159
+ expect(uploadMock.upload.mock.calls[0]?.[0].files).toEqual(files);
123
160
  });
124
161
 
125
- it('truncates files to available slots and calls onFilesExceeded', () => {
162
+ it('truncates files to available slots and calls onFilesExceeded', async () => {
126
163
  const provider = createDocumentUploadProvider(
127
164
  buildConfig({ maxFilesPerUpload: 2 }),
128
165
  );
129
166
  const { handleFilesSelected } = provider.useDocumentUpload('conv-1');
130
167
 
131
168
  const onFilesExceeded = vi.fn();
132
- const event = makeInputEvent([
169
+ const files = [
133
170
  createFile('a.pdf'),
134
171
  createFile('b.pdf'),
135
172
  createFile('c.pdf'),
136
173
  createFile('d.pdf'),
137
- ]);
174
+ ];
175
+ const event = makeInputEvent(files);
138
176
 
139
177
  handleFilesSelected(event, { onFilesExceeded });
140
178
 
@@ -143,9 +181,13 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
143
181
  allowed: 2,
144
182
  kept: 2,
145
183
  });
184
+ await settleAsyncUploads();
185
+ expect(uploadMock.upload.mock.calls[0]?.[0].files).toEqual(
186
+ files.slice(0, 2),
187
+ );
146
188
  });
147
189
 
148
- it('resets input value after handling', () => {
190
+ it('resets input value after handling', async () => {
149
191
  const provider = createDocumentUploadProvider(
150
192
  buildConfig({ maxFilesPerUpload: 5 }),
151
193
  );
@@ -159,6 +201,7 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
159
201
  handleFilesSelected(event);
160
202
 
161
203
  expect(valueSetter).toHaveBeenCalledWith('');
204
+ await settleAsyncUploads();
162
205
  });
163
206
 
164
207
  it('does not upload when no slots are available', async () => {
@@ -177,9 +220,12 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
177
220
  allowed: 0,
178
221
  kept: 0,
179
222
  });
223
+ await settleAsyncUploads();
224
+ expect(uploadMock.upload).not.toHaveBeenCalled();
225
+ expect(fetchMock).not.toHaveBeenCalled();
180
226
  });
181
227
 
182
- it('does not call onFilesExceeded when exactly at the limit', () => {
228
+ it('does not call onFilesExceeded when exactly at the limit', async () => {
183
229
  const provider = createDocumentUploadProvider(
184
230
  buildConfig({ maxFilesPerUpload: 3 }),
185
231
  );
@@ -195,9 +241,11 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
195
241
  handleFilesSelected(event, { onFilesExceeded });
196
242
 
197
243
  expect(onFilesExceeded).not.toHaveBeenCalled();
244
+ await settleAsyncUploads();
245
+ expect(uploadMock.upload).toHaveBeenCalledOnce();
198
246
  });
199
247
 
200
- it('works without onFilesExceeded callback (no crash)', () => {
248
+ it('works without onFilesExceeded callback (no crash)', async () => {
201
249
  const provider = createDocumentUploadProvider(
202
250
  buildConfig({ maxFilesPerUpload: 1 }),
203
251
  );
@@ -206,5 +254,7 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
206
254
  const event = makeInputEvent([createFile('a.pdf'), createFile('b.pdf')]);
207
255
 
208
256
  expect(() => handleFilesSelected(event)).not.toThrow();
257
+ await settleAsyncUploads();
258
+ expect(uploadMock.upload).toHaveBeenCalledOnce();
209
259
  });
210
260
  });