@librechat/agents 3.3.0 → 3.3.2

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 (130) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +116 -2
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +2 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +229 -48
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs +102 -27
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/index.cjs +11 -2
  10. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  11. package/dist/cjs/llm/contextOverflowRecovery.cjs +132 -0
  12. package/dist/cjs/llm/contextOverflowRecovery.cjs.map +1 -0
  13. package/dist/cjs/llm/google/index.cjs +1 -1
  14. package/dist/cjs/llm/invoke.cjs +60 -3
  15. package/dist/cjs/llm/invoke.cjs.map +1 -1
  16. package/dist/cjs/main.cjs +20 -7
  17. package/dist/cjs/messages/prune.cjs +16 -5
  18. package/dist/cjs/messages/prune.cjs.map +1 -1
  19. package/dist/cjs/messages/recency.cjs +2 -0
  20. package/dist/cjs/messages/recency.cjs.map +1 -1
  21. package/dist/cjs/run.cjs +15 -2
  22. package/dist/cjs/run.cjs.map +1 -1
  23. package/dist/cjs/stream.cjs +205 -49
  24. package/dist/cjs/stream.cjs.map +1 -1
  25. package/dist/cjs/summarization/node.cjs +55 -7
  26. package/dist/cjs/summarization/node.cjs.map +1 -1
  27. package/dist/cjs/tools/BashExecutor.cjs +2 -2
  28. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  29. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -2
  30. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  31. package/dist/cjs/tools/CodeExecutor.cjs +69 -8
  32. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  33. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +19 -15
  34. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  35. package/dist/cjs/tools/ToolNode.cjs +32 -3
  36. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  37. package/dist/cjs/utils/errors.cjs +317 -52
  38. package/dist/cjs/utils/errors.cjs.map +1 -1
  39. package/dist/esm/agents/AgentContext.mjs +117 -3
  40. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  41. package/dist/esm/common/enum.mjs +2 -0
  42. package/dist/esm/common/enum.mjs.map +1 -1
  43. package/dist/esm/graphs/Graph.mjs +227 -46
  44. package/dist/esm/graphs/Graph.mjs.map +1 -1
  45. package/dist/esm/graphs/MultiAgentGraph.mjs +102 -27
  46. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  47. package/dist/esm/llm/bedrock/index.mjs +11 -2
  48. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  49. package/dist/esm/llm/contextOverflowRecovery.mjs +130 -0
  50. package/dist/esm/llm/contextOverflowRecovery.mjs.map +1 -0
  51. package/dist/esm/llm/google/index.mjs +1 -1
  52. package/dist/esm/llm/invoke.mjs +59 -4
  53. package/dist/esm/llm/invoke.mjs.map +1 -1
  54. package/dist/esm/main.mjs +9 -9
  55. package/dist/esm/messages/prune.mjs +14 -6
  56. package/dist/esm/messages/prune.mjs.map +1 -1
  57. package/dist/esm/messages/recency.mjs +2 -1
  58. package/dist/esm/messages/recency.mjs.map +1 -1
  59. package/dist/esm/run.mjs +15 -2
  60. package/dist/esm/run.mjs.map +1 -1
  61. package/dist/esm/stream.mjs +205 -49
  62. package/dist/esm/stream.mjs.map +1 -1
  63. package/dist/esm/summarization/node.mjs +55 -7
  64. package/dist/esm/summarization/node.mjs.map +1 -1
  65. package/dist/esm/tools/BashExecutor.mjs +3 -3
  66. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  67. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +3 -3
  68. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  69. package/dist/esm/tools/CodeExecutor.mjs +62 -9
  70. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  71. package/dist/esm/tools/ProgrammaticToolCalling.mjs +20 -16
  72. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  73. package/dist/esm/tools/ToolNode.mjs +32 -3
  74. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  75. package/dist/esm/utils/errors.mjs +317 -53
  76. package/dist/esm/utils/errors.mjs.map +1 -1
  77. package/dist/types/agents/AgentContext.d.ts +62 -3
  78. package/dist/types/common/enum.d.ts +2 -0
  79. package/dist/types/graphs/Graph.d.ts +16 -2
  80. package/dist/types/graphs/MultiAgentGraph.d.ts +1 -0
  81. package/dist/types/llm/contextOverflowRecovery.d.ts +85 -0
  82. package/dist/types/llm/invoke.d.ts +35 -6
  83. package/dist/types/messages/prune.d.ts +10 -2
  84. package/dist/types/messages/recency.d.ts +1 -0
  85. package/dist/types/run.d.ts +2 -0
  86. package/dist/types/tools/CodeExecutor.d.ts +14 -1
  87. package/dist/types/types/llm.d.ts +7 -4
  88. package/dist/types/types/stream.d.ts +5 -4
  89. package/dist/types/types/summarize.d.ts +22 -0
  90. package/dist/types/utils/__tests__/fixtures/contextOverflowSignatures.d.ts +40 -0
  91. package/dist/types/utils/errors.d.ts +65 -16
  92. package/dist/types/utils/redactSecrets.d.ts +3 -0
  93. package/package.json +10 -11
  94. package/src/agents/AgentContext.ts +188 -7
  95. package/src/agents/__tests__/AgentContext.overflow.test.ts +205 -0
  96. package/src/common/enum.ts +2 -0
  97. package/src/graphs/Graph.ts +389 -58
  98. package/src/graphs/MultiAgentGraph.ts +184 -46
  99. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +631 -0
  100. package/src/llm/__tests__/contextOverflowRecovery.test.ts +401 -0
  101. package/src/llm/__tests__/fallbackOverflow.test.ts +287 -0
  102. package/src/llm/anthropic/llm.spec.ts +8 -0
  103. package/src/llm/bedrock/index.ts +25 -12
  104. package/src/llm/contextOverflowRecovery.ts +292 -0
  105. package/src/llm/invoke.ts +119 -4
  106. package/src/messages/prune.ts +24 -11
  107. package/src/messages/recency.ts +3 -1
  108. package/src/run.ts +24 -1
  109. package/src/scripts/context-overflow-probe.ts +997 -0
  110. package/src/specs/agent-handoffs.test.ts +903 -1
  111. package/src/specs/context-overflow-recovery.live.test.ts +213 -0
  112. package/src/splitStream.test.ts +882 -0
  113. package/src/stream.ts +315 -51
  114. package/src/summarization/__tests__/aggregator.test.ts +83 -0
  115. package/src/summarization/__tests__/node.test.ts +139 -0
  116. package/src/summarization/node.ts +99 -14
  117. package/src/tools/BashExecutor.ts +4 -2
  118. package/src/tools/BashProgrammaticToolCalling.ts +4 -7
  119. package/src/tools/CodeExecutor.ts +119 -8
  120. package/src/tools/ProgrammaticToolCalling.ts +29 -27
  121. package/src/tools/ToolNode.ts +50 -8
  122. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +297 -3
  123. package/src/types/llm.ts +8 -1
  124. package/src/types/stream.ts +5 -4
  125. package/src/types/summarize.ts +22 -0
  126. package/src/utils/__tests__/errors.test.ts +270 -0
  127. package/src/utils/__tests__/fixtures/contextOverflowSignatures.ts +336 -0
  128. package/src/utils/__tests__/redactSecrets.test.ts +56 -0
  129. package/src/utils/errors.ts +484 -66
  130. package/src/utils/redactSecrets.ts +61 -0
@@ -225,6 +225,888 @@ describe('ContentAggregator empty deltas', () => {
225
225
  });
226
226
  });
227
227
 
228
+ describe('ContentAggregator resumed tool completions', () => {
229
+ it('matches a completion to seeded content when a rebuilt run has no step id', () => {
230
+ const { contentParts, aggregateContent } = createContentAggregator();
231
+ contentParts.push(
232
+ { type: ContentTypes.TEXT, text: 'Before approval' },
233
+ {
234
+ type: ContentTypes.TOOL_CALL,
235
+ tool_call: {
236
+ id: 'call_approval',
237
+ name: 'approval_probe',
238
+ args: { value: 'original' },
239
+ },
240
+ }
241
+ );
242
+
243
+ aggregateContent({
244
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
245
+ data: {
246
+ result: {
247
+ id: '',
248
+ index: 0,
249
+ type: 'tool_call',
250
+ tool_call: {
251
+ id: 'call_approval',
252
+ name: 'approval_probe',
253
+ args: '{"value":"original"}',
254
+ output: 'approved',
255
+ progress: 1,
256
+ } as t.ProcessedToolCall,
257
+ },
258
+ } as { result: t.ToolEndEvent },
259
+ });
260
+
261
+ expect(contentParts[0]).toEqual({
262
+ type: ContentTypes.TEXT,
263
+ text: 'Before approval',
264
+ });
265
+ expect(contentParts[1]).toEqual({
266
+ type: ContentTypes.TOOL_CALL,
267
+ tool_call: {
268
+ id: 'call_approval',
269
+ name: 'approval_probe',
270
+ args: '{"value":"original"}',
271
+ type: 'tool_call',
272
+ output: 'approved',
273
+ progress: 1,
274
+ },
275
+ });
276
+
277
+ aggregateContent({
278
+ event: GraphEvents.ON_RUN_STEP,
279
+ data: createRunStep('step_continuation'),
280
+ });
281
+ aggregateContent({
282
+ event: GraphEvents.ON_MESSAGE_DELTA,
283
+ data: {
284
+ id: 'step_continuation',
285
+ delta: {
286
+ content: [
287
+ {
288
+ type: ContentTypes.TEXT,
289
+ text: 'After approval',
290
+ },
291
+ ],
292
+ },
293
+ } as t.MessageDeltaEvent,
294
+ });
295
+
296
+ expect(contentParts[2]).toEqual({
297
+ type: ContentTypes.TEXT,
298
+ text: 'After approval',
299
+ });
300
+ });
301
+
302
+ it('rebinds a replacement run step to seeded content by tool call id', () => {
303
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
304
+ const { contentParts, aggregateContent, stepMap } =
305
+ createContentAggregator();
306
+ contentParts.push(
307
+ { type: ContentTypes.TEXT, text: 'Before approval' },
308
+ {
309
+ type: ContentTypes.TOOL_CALL,
310
+ tool_call: {
311
+ id: 'call_approval',
312
+ name: 'approval_probe',
313
+ args: { value: 'original' },
314
+ },
315
+ }
316
+ );
317
+
318
+ try {
319
+ aggregateContent({
320
+ event: GraphEvents.ON_RUN_STEP,
321
+ data: {
322
+ id: 'step_rebuilt',
323
+ stepIndex: 0,
324
+ type: StepTypes.TOOL_CALLS,
325
+ index: 0,
326
+ stepDetails: {
327
+ type: StepTypes.TOOL_CALLS,
328
+ tool_calls: [
329
+ {
330
+ id: 'call_approval',
331
+ name: 'approval_probe',
332
+ args: { value: 'original' },
333
+ },
334
+ ],
335
+ },
336
+ usage: null,
337
+ },
338
+ });
339
+ aggregateContent({
340
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
341
+ data: {
342
+ result: {
343
+ id: 'step_rebuilt',
344
+ index: 0,
345
+ type: 'tool_call',
346
+ tool_call: {
347
+ id: 'call_approval',
348
+ name: 'approval_probe',
349
+ args: '{"value":"original"}',
350
+ output: 'approved',
351
+ progress: 1,
352
+ } as t.ProcessedToolCall,
353
+ },
354
+ } as { result: t.ToolEndEvent },
355
+ });
356
+
357
+ expect(stepMap.get('step_rebuilt')?.index).toBe(1);
358
+ expect(contentParts[0]).toEqual({
359
+ type: ContentTypes.TEXT,
360
+ text: 'Before approval',
361
+ });
362
+ expect(contentParts[1]).toMatchObject({
363
+ type: ContentTypes.TOOL_CALL,
364
+ tool_call: {
365
+ id: 'call_approval',
366
+ output: 'approved',
367
+ progress: 1,
368
+ },
369
+ });
370
+ aggregateContent({
371
+ event: GraphEvents.ON_RUN_STEP,
372
+ data: { ...createRunStep('step_continuation'), index: 1 },
373
+ });
374
+ aggregateContent({
375
+ event: GraphEvents.ON_MESSAGE_DELTA,
376
+ data: {
377
+ id: 'step_continuation',
378
+ delta: {
379
+ content: [
380
+ {
381
+ type: ContentTypes.TEXT,
382
+ text: 'After approval',
383
+ },
384
+ ],
385
+ },
386
+ } as t.MessageDeltaEvent,
387
+ });
388
+ expect(stepMap.get('step_continuation')?.index).toBe(2);
389
+ expect(contentParts[2]).toEqual({
390
+ type: ContentTypes.TEXT,
391
+ text: 'After approval',
392
+ });
393
+ expect(warnSpy).not.toHaveBeenCalled();
394
+ } finally {
395
+ warnSpy.mockRestore();
396
+ }
397
+ });
398
+
399
+ it('matches parallel completions to their own seeded tool cards', () => {
400
+ const { contentParts, aggregateContent } = createContentAggregator();
401
+ contentParts.push(
402
+ { type: ContentTypes.TEXT, text: 'Before approval' },
403
+ {
404
+ type: ContentTypes.TOOL_CALL,
405
+ tool_call: {
406
+ id: 'call_first',
407
+ name: 'approval_probe',
408
+ args: { value: 'first' },
409
+ },
410
+ },
411
+ {
412
+ type: ContentTypes.TOOL_CALL,
413
+ tool_call: {
414
+ id: 'call_second',
415
+ name: 'approval_probe',
416
+ args: { value: 'second' },
417
+ },
418
+ }
419
+ );
420
+
421
+ aggregateContent({
422
+ event: GraphEvents.ON_RUN_STEP,
423
+ data: {
424
+ id: 'step_parallel',
425
+ stepIndex: 0,
426
+ type: StepTypes.TOOL_CALLS,
427
+ index: 0,
428
+ stepDetails: {
429
+ type: StepTypes.TOOL_CALLS,
430
+ tool_calls: [
431
+ {
432
+ id: 'call_first',
433
+ name: 'approval_probe',
434
+ args: { value: 'first' },
435
+ },
436
+ {
437
+ id: 'call_second',
438
+ name: 'approval_probe',
439
+ args: { value: 'second' },
440
+ },
441
+ ],
442
+ },
443
+ usage: null,
444
+ },
445
+ });
446
+
447
+ for (const [id, value] of [
448
+ ['call_first', 'first'],
449
+ ['call_second', 'second'],
450
+ ]) {
451
+ aggregateContent({
452
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
453
+ data: {
454
+ result: {
455
+ id: 'step_parallel',
456
+ index: 0,
457
+ type: 'tool_call',
458
+ tool_call: {
459
+ id,
460
+ name: 'approval_probe',
461
+ args: JSON.stringify({ value }),
462
+ output: `${value} approved`,
463
+ progress: 1,
464
+ } as t.ProcessedToolCall,
465
+ },
466
+ } as { result: t.ToolEndEvent },
467
+ });
468
+ }
469
+
470
+ expect(contentParts[1]).toMatchObject({
471
+ type: ContentTypes.TOOL_CALL,
472
+ tool_call: {
473
+ id: 'call_first',
474
+ output: 'first approved',
475
+ progress: 1,
476
+ },
477
+ });
478
+ expect(contentParts[2]).toMatchObject({
479
+ type: ContentTypes.TOOL_CALL,
480
+ tool_call: {
481
+ id: 'call_second',
482
+ output: 'second approved',
483
+ progress: 1,
484
+ },
485
+ });
486
+
487
+ aggregateContent({
488
+ event: GraphEvents.ON_RUN_STEP,
489
+ data: { ...createRunStep('step_after_parallel'), index: 1 },
490
+ });
491
+ aggregateContent({
492
+ event: GraphEvents.ON_MESSAGE_DELTA,
493
+ data: {
494
+ id: 'step_after_parallel',
495
+ delta: {
496
+ content: [
497
+ {
498
+ type: ContentTypes.TEXT,
499
+ text: 'Parallel tools complete',
500
+ },
501
+ ],
502
+ },
503
+ } as t.MessageDeltaEvent,
504
+ });
505
+ expect(contentParts[3]).toEqual({
506
+ type: ContentTypes.TEXT,
507
+ text: 'Parallel tools complete',
508
+ });
509
+ });
510
+
511
+ it('appends unmatched tools and continuation content after the entire seed', () => {
512
+ const { contentParts, aggregateContent, stepMap } =
513
+ createContentAggregator();
514
+ contentParts.push(
515
+ { type: ContentTypes.TEXT, text: 'Before approval' },
516
+ {
517
+ type: ContentTypes.TOOL_CALL,
518
+ tool_call: {
519
+ id: 'call_approval',
520
+ name: 'approval_probe',
521
+ args: {},
522
+ },
523
+ },
524
+ { type: ContentTypes.TEXT, text: 'Seeded tail one' },
525
+ { type: ContentTypes.TEXT, text: 'Seeded tail two' }
526
+ );
527
+
528
+ aggregateContent({
529
+ event: GraphEvents.ON_RUN_STEP,
530
+ data: {
531
+ id: 'step_rebuilt',
532
+ stepIndex: 0,
533
+ type: StepTypes.TOOL_CALLS,
534
+ index: 0,
535
+ stepDetails: {
536
+ type: StepTypes.TOOL_CALLS,
537
+ tool_calls: [
538
+ {
539
+ id: 'call_approval',
540
+ name: 'approval_probe',
541
+ args: {},
542
+ },
543
+ {
544
+ id: 'call_new',
545
+ name: 'approval_probe',
546
+ args: { value: 'new' },
547
+ },
548
+ ],
549
+ },
550
+ usage: null,
551
+ },
552
+ });
553
+ aggregateContent({
554
+ event: GraphEvents.ON_RUN_STEP,
555
+ data: { ...createRunStep('step_after_seed'), index: 1 },
556
+ });
557
+ aggregateContent({
558
+ event: GraphEvents.ON_MESSAGE_DELTA,
559
+ data: {
560
+ id: 'step_after_seed',
561
+ delta: {
562
+ content: [{ type: ContentTypes.TEXT, text: 'After seed' }],
563
+ },
564
+ } as t.MessageDeltaEvent,
565
+ });
566
+
567
+ expect(stepMap.get('step_rebuilt')?.index).toBe(1);
568
+ expect(stepMap.get('step_after_seed')?.index).toBe(5);
569
+ expect(contentParts.slice(2, 4)).toEqual([
570
+ { type: ContentTypes.TEXT, text: 'Seeded tail one' },
571
+ { type: ContentTypes.TEXT, text: 'Seeded tail two' },
572
+ ]);
573
+ expect(contentParts[4]).toMatchObject({
574
+ type: ContentTypes.TOOL_CALL,
575
+ tool_call: {
576
+ id: 'call_new',
577
+ args: { value: 'new' },
578
+ },
579
+ });
580
+ expect(contentParts[5]).toEqual({
581
+ type: ContentTypes.TEXT,
582
+ text: 'After seed',
583
+ });
584
+ });
585
+
586
+ it('preserves seeded metadata when a blank-id completion replaces a tool card', () => {
587
+ const { contentParts, aggregateContent, stepMap } =
588
+ createContentAggregator();
589
+ contentParts.push({
590
+ type: ContentTypes.TOOL_CALL,
591
+ agentId: 'researcher',
592
+ groupId: 7,
593
+ tool_call: {
594
+ id: 'call_with_metadata',
595
+ name: 'approval_probe',
596
+ args: {},
597
+ },
598
+ });
599
+
600
+ aggregateContent({
601
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
602
+ data: {
603
+ result: {
604
+ id: '',
605
+ index: 42,
606
+ type: 'tool_call',
607
+ tool_call: {
608
+ id: 'call_with_metadata',
609
+ name: 'approval_probe',
610
+ args: '{}',
611
+ output: 'approved',
612
+ progress: 1,
613
+ } as t.ProcessedToolCall,
614
+ },
615
+ } as { result: t.ToolEndEvent },
616
+ });
617
+
618
+ expect(contentParts[0]).toMatchObject({
619
+ agentId: 'researcher',
620
+ groupId: 7,
621
+ tool_call: {
622
+ id: 'call_with_metadata',
623
+ output: 'approved',
624
+ },
625
+ });
626
+
627
+ aggregateContent({
628
+ event: GraphEvents.ON_RUN_STEP,
629
+ data: createRunStep('step_after_large_completion_index'),
630
+ });
631
+ aggregateContent({
632
+ event: GraphEvents.ON_MESSAGE_DELTA,
633
+ data: {
634
+ id: 'step_after_large_completion_index',
635
+ delta: {
636
+ content: [{ type: ContentTypes.TEXT, text: 'Still dense' }],
637
+ },
638
+ } as t.MessageDeltaEvent,
639
+ });
640
+ expect(stepMap.get('step_after_large_completion_index')?.index).toBe(1);
641
+ expect(contentParts[1]).toEqual({
642
+ type: ContentTypes.TEXT,
643
+ text: 'Still dense',
644
+ });
645
+ });
646
+
647
+ it('applies metadata from a hydrated run step to its seeded tool card', () => {
648
+ const { contentParts, aggregateContent, stepMap } =
649
+ createContentAggregator();
650
+ contentParts.push({
651
+ type: ContentTypes.TOOL_CALL,
652
+ tool_call: {
653
+ id: 'call_hydrated',
654
+ name: 'approval_probe',
655
+ args: {},
656
+ },
657
+ });
658
+ stepMap.set('step_hydrated', {
659
+ id: 'step_hydrated',
660
+ stepIndex: 0,
661
+ type: StepTypes.TOOL_CALLS,
662
+ index: 0,
663
+ agentId: 'reviewer',
664
+ groupId: 11,
665
+ stepDetails: {
666
+ type: StepTypes.TOOL_CALLS,
667
+ tool_calls: [
668
+ {
669
+ id: 'call_hydrated',
670
+ name: 'approval_probe',
671
+ args: {},
672
+ },
673
+ ],
674
+ },
675
+ usage: null,
676
+ });
677
+
678
+ aggregateContent({
679
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
680
+ data: {
681
+ result: {
682
+ id: 'step_hydrated',
683
+ index: 0,
684
+ type: 'tool_call',
685
+ tool_call: {
686
+ id: 'call_hydrated',
687
+ name: 'approval_probe',
688
+ args: '{}',
689
+ output: 'approved',
690
+ progress: 1,
691
+ } as t.ProcessedToolCall,
692
+ },
693
+ } as { result: t.ToolEndEvent },
694
+ });
695
+
696
+ expect(contentParts[0]).toMatchObject({
697
+ agentId: 'reviewer',
698
+ groupId: 11,
699
+ tool_call: { output: 'approved' },
700
+ });
701
+ });
702
+
703
+ it('drops an unknown completion for a parallel step', () => {
704
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
705
+ const { contentParts, aggregateContent } = createContentAggregator();
706
+ try {
707
+ aggregateContent({
708
+ event: GraphEvents.ON_RUN_STEP,
709
+ data: {
710
+ id: 'step_parallel_unknown',
711
+ stepIndex: 0,
712
+ type: StepTypes.TOOL_CALLS,
713
+ index: 0,
714
+ stepDetails: {
715
+ type: StepTypes.TOOL_CALLS,
716
+ tool_calls: [
717
+ { id: 'call_known_first', name: 'first', args: {} },
718
+ { id: 'call_known_second', name: 'second', args: {} },
719
+ ],
720
+ },
721
+ usage: null,
722
+ },
723
+ });
724
+ aggregateContent({
725
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
726
+ data: {
727
+ result: {
728
+ id: 'step_parallel_unknown',
729
+ index: 999,
730
+ type: 'tool_call',
731
+ tool_call: {
732
+ id: 'call_unknown',
733
+ name: 'unknown',
734
+ args: '{}',
735
+ output: 'must not overwrite',
736
+ progress: 1,
737
+ } as t.ProcessedToolCall,
738
+ },
739
+ } as { result: t.ToolEndEvent },
740
+ });
741
+
742
+ expect(contentParts).toHaveLength(2);
743
+ expect(contentParts[0]?.type).toBe(ContentTypes.TOOL_CALL);
744
+ expect(contentParts[1]?.type).toBe(ContentTypes.TOOL_CALL);
745
+ expect(warnSpy).toHaveBeenCalledWith(
746
+ 'No run step or tool call found for completed step event'
747
+ );
748
+ } finally {
749
+ warnSpy.mockRestore();
750
+ }
751
+ });
752
+
753
+ it('drops a completion that matches neither a step nor seeded tool call', () => {
754
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
755
+ const { contentParts, aggregateContent } = createContentAggregator();
756
+
757
+ try {
758
+ aggregateContent({
759
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
760
+ data: {
761
+ result: {
762
+ id: '',
763
+ index: 0,
764
+ type: 'tool_call',
765
+ tool_call: {
766
+ id: 'call_unknown',
767
+ name: 'unknown',
768
+ args: '{}',
769
+ output: 'untrusted',
770
+ progress: 1,
771
+ } as t.ProcessedToolCall,
772
+ },
773
+ } as { result: t.ToolEndEvent },
774
+ });
775
+
776
+ expect(contentParts).toEqual([]);
777
+ expect(warnSpy).toHaveBeenCalledWith(
778
+ 'No run step or tool call found for completed step event'
779
+ );
780
+ } finally {
781
+ warnSpy.mockRestore();
782
+ }
783
+ });
784
+ });
785
+
786
+ describe('ContentAggregator physical content indices', () => {
787
+ it('allocates physical slots independently from streamed chunk keys', () => {
788
+ const { contentParts, aggregateContent } = createContentAggregator();
789
+ aggregateContent({
790
+ event: GraphEvents.ON_RUN_STEP,
791
+ data: {
792
+ id: 'step_streamed_tool',
793
+ stepIndex: 0,
794
+ type: StepTypes.TOOL_CALLS,
795
+ index: 0,
796
+ stepDetails: {
797
+ type: StepTypes.TOOL_CALLS,
798
+ tool_calls: [],
799
+ },
800
+ usage: null,
801
+ },
802
+ });
803
+ aggregateContent({
804
+ event: GraphEvents.ON_RUN_STEP_DELTA,
805
+ data: {
806
+ id: 'step_streamed_tool',
807
+ delta: {
808
+ type: StepTypes.TOOL_CALLS,
809
+ tool_calls: [
810
+ {
811
+ index: 2,
812
+ id: 'call_streamed_first',
813
+ name: 'approval_probe',
814
+ args: '{"value":"first"}',
815
+ },
816
+ {
817
+ index: 5,
818
+ id: 'call_streamed_second',
819
+ name: 'approval_probe',
820
+ args: '{"value":"second"}',
821
+ },
822
+ ],
823
+ },
824
+ } as t.RunStepDeltaEvent,
825
+ });
826
+
827
+ expect(contentParts).toHaveLength(2);
828
+ expect(contentParts[0]).toMatchObject({
829
+ type: ContentTypes.TOOL_CALL,
830
+ tool_call: {
831
+ id: 'call_streamed_first',
832
+ name: 'approval_probe',
833
+ },
834
+ });
835
+ expect(contentParts[1]).toMatchObject({
836
+ type: ContentTypes.TOOL_CALL,
837
+ tool_call: {
838
+ id: 'call_streamed_second',
839
+ name: 'approval_probe',
840
+ },
841
+ });
842
+
843
+ for (const [id, output] of [
844
+ ['call_streamed_first', 'first complete'],
845
+ ['call_streamed_second', 'second complete'],
846
+ ]) {
847
+ aggregateContent({
848
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
849
+ data: {
850
+ result: {
851
+ id: 'step_streamed_tool',
852
+ index: 500,
853
+ type: 'tool_call',
854
+ tool_call: {
855
+ id,
856
+ name: 'approval_probe',
857
+ args: '{}',
858
+ output,
859
+ progress: 1,
860
+ } as t.ProcessedToolCall,
861
+ },
862
+ } as { result: t.ToolEndEvent },
863
+ });
864
+ }
865
+ expect(contentParts[0]).toMatchObject({
866
+ tool_call: { output: 'first complete' },
867
+ });
868
+ expect(contentParts[1]).toMatchObject({
869
+ tool_call: { output: 'second complete' },
870
+ });
871
+ });
872
+
873
+ it('keeps distinct chunk keys separate when tool ids arrive late', () => {
874
+ const { contentParts, aggregateContent } = createContentAggregator();
875
+ aggregateContent({
876
+ event: GraphEvents.ON_RUN_STEP,
877
+ data: {
878
+ id: 'step_late_ids',
879
+ stepIndex: 0,
880
+ type: StepTypes.TOOL_CALLS,
881
+ index: 0,
882
+ stepDetails: {
883
+ type: StepTypes.TOOL_CALLS,
884
+ tool_calls: [],
885
+ },
886
+ usage: null,
887
+ },
888
+ });
889
+ aggregateContent({
890
+ event: GraphEvents.ON_RUN_STEP_DELTA,
891
+ data: {
892
+ id: 'step_late_ids',
893
+ delta: {
894
+ type: StepTypes.TOOL_CALLS,
895
+ tool_calls: [
896
+ { index: 2, name: 'first', args: '{"value":"first"}' },
897
+ ],
898
+ },
899
+ } as t.RunStepDeltaEvent,
900
+ });
901
+ aggregateContent({
902
+ event: GraphEvents.ON_RUN_STEP_DELTA,
903
+ data: {
904
+ id: 'step_late_ids',
905
+ delta: {
906
+ type: StepTypes.TOOL_CALLS,
907
+ tool_calls: [
908
+ { index: 5, name: 'second', args: '{"value":"second"}' },
909
+ ],
910
+ },
911
+ } as t.RunStepDeltaEvent,
912
+ });
913
+ aggregateContent({
914
+ event: GraphEvents.ON_RUN_STEP_DELTA,
915
+ data: {
916
+ id: 'step_late_ids',
917
+ delta: {
918
+ type: StepTypes.TOOL_CALLS,
919
+ tool_calls: [
920
+ { index: 2, id: 'call_late_first', name: 'first', args: {} },
921
+ { index: 5, id: 'call_late_second', name: 'second', args: {} },
922
+ ],
923
+ },
924
+ } as t.RunStepDeltaEvent,
925
+ });
926
+
927
+ expect(contentParts).toHaveLength(2);
928
+ expect(contentParts[0]).toMatchObject({
929
+ tool_call: {
930
+ id: 'call_late_first',
931
+ name: 'first',
932
+ },
933
+ });
934
+ expect(contentParts[1]).toMatchObject({
935
+ tool_call: {
936
+ id: 'call_late_second',
937
+ name: 'second',
938
+ },
939
+ });
940
+ });
941
+
942
+ it('uses the reserved slot when a streamed tool id has no chunk index', () => {
943
+ const { contentParts, aggregateContent } = createContentAggregator();
944
+ aggregateContent({
945
+ event: GraphEvents.ON_RUN_STEP,
946
+ data: {
947
+ id: 'step_without_chunk_index',
948
+ stepIndex: 0,
949
+ type: StepTypes.TOOL_CALLS,
950
+ index: 0,
951
+ stepDetails: {
952
+ type: StepTypes.TOOL_CALLS,
953
+ tool_calls: [],
954
+ },
955
+ usage: null,
956
+ },
957
+ });
958
+ aggregateContent({
959
+ event: GraphEvents.ON_RUN_STEP_DELTA,
960
+ data: {
961
+ id: 'step_without_chunk_index',
962
+ delta: {
963
+ type: StepTypes.TOOL_CALLS,
964
+ tool_calls: [
965
+ {
966
+ id: 'call_without_chunk_index',
967
+ name: 'approval_probe',
968
+ args: '{}',
969
+ },
970
+ ],
971
+ },
972
+ } as t.RunStepDeltaEvent,
973
+ });
974
+
975
+ expect(contentParts).toHaveLength(1);
976
+ expect(contentParts[0]).toMatchObject({
977
+ tool_call: {
978
+ id: 'call_without_chunk_index',
979
+ name: 'approval_probe',
980
+ },
981
+ });
982
+ });
983
+
984
+ it('routes an id-less chunk to the only known tool card', () => {
985
+ const { contentParts, aggregateContent } = createContentAggregator();
986
+ aggregateContent({
987
+ event: GraphEvents.ON_RUN_STEP,
988
+ data: {
989
+ id: 'step_single_tool',
990
+ stepIndex: 0,
991
+ type: StepTypes.TOOL_CALLS,
992
+ index: 0,
993
+ stepDetails: {
994
+ type: StepTypes.TOOL_CALLS,
995
+ tool_calls: [
996
+ {
997
+ id: 'call_single',
998
+ name: 'approval_probe',
999
+ args: {},
1000
+ },
1001
+ ],
1002
+ },
1003
+ usage: null,
1004
+ },
1005
+ });
1006
+ aggregateContent({
1007
+ event: GraphEvents.ON_RUN_STEP_DELTA,
1008
+ data: {
1009
+ id: 'step_single_tool',
1010
+ delta: {
1011
+ type: StepTypes.TOOL_CALLS,
1012
+ tool_calls: [
1013
+ {
1014
+ index: 9,
1015
+ name: 'approval_probe',
1016
+ args: '{"value":"updated"}',
1017
+ },
1018
+ ],
1019
+ },
1020
+ } as t.RunStepDeltaEvent,
1021
+ });
1022
+
1023
+ expect(contentParts).toHaveLength(1);
1024
+ expect(contentParts[0]).toMatchObject({
1025
+ type: ContentTypes.TOOL_CALL,
1026
+ tool_call: {
1027
+ id: 'call_single',
1028
+ args: '{"value":"updated"}',
1029
+ },
1030
+ });
1031
+ });
1032
+
1033
+ it('routes indexed id-less chunks across known parallel tool cards', () => {
1034
+ const { contentParts, aggregateContent } = createContentAggregator();
1035
+ aggregateContent({
1036
+ event: GraphEvents.ON_RUN_STEP,
1037
+ data: {
1038
+ id: 'step_parallel_chunks',
1039
+ stepIndex: 0,
1040
+ type: StepTypes.TOOL_CALLS,
1041
+ index: 0,
1042
+ stepDetails: {
1043
+ type: StepTypes.TOOL_CALLS,
1044
+ tool_calls: [
1045
+ { id: 'call_chunk_first', name: 'first', args: {} },
1046
+ { id: 'call_chunk_second', name: 'second', args: {} },
1047
+ ],
1048
+ },
1049
+ usage: null,
1050
+ },
1051
+ });
1052
+ aggregateContent({
1053
+ event: GraphEvents.ON_RUN_STEP_DELTA,
1054
+ data: {
1055
+ id: 'step_parallel_chunks',
1056
+ delta: {
1057
+ type: StepTypes.TOOL_CALLS,
1058
+ tool_calls: [
1059
+ { index: 7, name: 'first', args: '{"value":"first"}' },
1060
+ { index: 9, name: 'second', args: '{"value":"second"}' },
1061
+ ],
1062
+ },
1063
+ } as t.RunStepDeltaEvent,
1064
+ });
1065
+
1066
+ expect(contentParts[0]).toMatchObject({
1067
+ tool_call: {
1068
+ id: 'call_chunk_first',
1069
+ args: '{"value":"first"}',
1070
+ },
1071
+ });
1072
+ expect(contentParts[1]).toMatchObject({
1073
+ tool_call: {
1074
+ id: 'call_chunk_second',
1075
+ args: '{"value":"second"}',
1076
+ },
1077
+ });
1078
+ });
1079
+
1080
+ it('places a raw agent update after seeded content', () => {
1081
+ const { contentParts, aggregateContent } = createContentAggregator();
1082
+ contentParts.push(
1083
+ { type: ContentTypes.TEXT, text: 'Seed one' },
1084
+ { type: ContentTypes.TEXT, text: 'Seed two' }
1085
+ );
1086
+
1087
+ aggregateContent({
1088
+ event: GraphEvents.ON_AGENT_UPDATE,
1089
+ data: {
1090
+ type: ContentTypes.AGENT_UPDATE,
1091
+ agent_update: {
1092
+ index: 0,
1093
+ runId: 'run_resumed',
1094
+ agentId: 'researcher',
1095
+ },
1096
+ },
1097
+ });
1098
+
1099
+ expect(contentParts[2]).toEqual({
1100
+ type: ContentTypes.AGENT_UPDATE,
1101
+ agent_update: {
1102
+ index: 2,
1103
+ runId: 'run_resumed',
1104
+ agentId: 'researcher',
1105
+ },
1106
+ });
1107
+ });
1108
+ });
1109
+
228
1110
  describe('ContentAggregator provider-specific parts', () => {
229
1111
  it('should preserve Gemini server-side tool content blocks', () => {
230
1112
  const { contentParts, aggregateContent } = createContentAggregator();