@illuma-ai/agents 1.1.1 → 1.1.3

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.
@@ -518,3 +518,348 @@ describe('All Features Combined — Full Pipeline', () => {
518
518
  expect(callback).toHaveBeenCalled();
519
519
  });
520
520
  });
521
+
522
+ // ===========================================================================
523
+ // Proactive Summarization — Context Pressure
524
+ // ===========================================================================
525
+
526
+ import { getContextUtilization } from '@/messages/prune';
527
+ import { PROACTIVE_SUMMARY_THRESHOLD } from '@/common/constants';
528
+
529
+ describe('Proactive Summarization — Context Pressure', () => {
530
+ it('triggers proactive summary at 80% utilization BEFORE pruning', () => {
531
+ // Simulate context at 82% utilization
532
+ const maxContextTokens = 200_000;
533
+ const indexTokenCountMap: Record<string, number | undefined> = {};
534
+ // Build messages that fill ~82% of context
535
+ const msgsNeeded = 40;
536
+ const tokensPerMsg = Math.floor((maxContextTokens * 0.82) / msgsNeeded);
537
+ for (let i = 0; i < msgsNeeded; i++) {
538
+ indexTokenCountMap[String(i)] = tokensPerMsg;
539
+ }
540
+
541
+ const utilization = getContextUtilization(indexTokenCountMap, 0, maxContextTokens);
542
+ const threshold = PROACTIVE_SUMMARY_THRESHOLD * 100; // 80
543
+
544
+ expect(utilization).toBeGreaterThanOrEqual(threshold);
545
+ // At 82%, proactive summary should fire
546
+ // But pruning should NOT have happened yet (context < 90% safety factor)
547
+ const effectiveBudget = Math.floor(maxContextTokens * 0.9); // CONTEXT_SAFETY_FACTOR
548
+ const totalTokens = Object.values(indexTokenCountMap).reduce((s, v) => (s ?? 0) + (v ?? 0), 0) as number;
549
+ expect(totalTokens).toBeLessThan(effectiveBudget);
550
+ });
551
+
552
+ it('does NOT trigger proactive summary below 80%', () => {
553
+ const maxContextTokens = 200_000;
554
+ const indexTokenCountMap: Record<string, number | undefined> = {};
555
+ // Fill to 50% utilization
556
+ const msgsNeeded = 20;
557
+ const tokensPerMsg = Math.floor((maxContextTokens * 0.5) / msgsNeeded);
558
+ for (let i = 0; i < msgsNeeded; i++) {
559
+ indexTokenCountMap[String(i)] = tokensPerMsg;
560
+ }
561
+
562
+ const utilization = getContextUtilization(indexTokenCountMap, 0, maxContextTokens);
563
+ expect(utilization).toBeLessThan(PROACTIVE_SUMMARY_THRESHOLD * 100);
564
+ });
565
+
566
+ it('selects only older messages for proactive summarization (keeps recent turns)', () => {
567
+ const messages: BaseMessage[] = [
568
+ new SystemMessage('System prompt'),
569
+ ...Array.from({ length: 20 }, (_, i) =>
570
+ i % 2 === 0
571
+ ? new HumanMessage(`User message ${i}`)
572
+ : new AIMessage(`AI response ${i}`)
573
+ ),
574
+ ];
575
+
576
+ // Simulate the selection logic from Graph.ts proactive summarization
577
+ const recentTurnCount = Math.max(4, Math.floor(messages.length * 0.3));
578
+ const oldMessages = messages.slice(
579
+ 1, // skip system message
580
+ Math.max(1, messages.length - recentTurnCount)
581
+ );
582
+
583
+ // Recent 30% (~6 messages) preserved, older messages selected for summary
584
+ expect(oldMessages.length).toBeLessThan(messages.length);
585
+ expect(oldMessages.length).toBeGreaterThan(0);
586
+ // System message not included
587
+ expect(oldMessages[0].getType()).not.toBe('system');
588
+ // Last messages of conversation not included (recent turns preserved)
589
+ const lastOldIndex = messages.indexOf(oldMessages[oldMessages.length - 1]);
590
+ expect(lastOldIndex).toBeLessThan(messages.length - recentTurnCount);
591
+ });
592
+
593
+ it('never blocks — proactive summary is always fire-and-forget', async () => {
594
+ let resolveCallback: ((v: string) => void) | undefined;
595
+ const slowCallback = jest.fn(
596
+ () =>
597
+ new Promise<string>((resolve) => {
598
+ resolveCallback = resolve;
599
+ })
600
+ );
601
+
602
+ // Simulate proactive summary fire-and-forget
603
+ const summaryPromise = slowCallback().then((updated) => {
604
+ return updated;
605
+ });
606
+
607
+ // Main flow continues immediately — callback hasn't resolved yet
608
+ expect(slowCallback).toHaveBeenCalledTimes(1);
609
+
610
+ // Later, callback resolves (simulating Nova Micro responding)
611
+ resolveCallback!('Proactive summary result');
612
+ const result = await summaryPromise;
613
+ expect(result).toBe('Proactive summary result');
614
+ });
615
+
616
+ it('at 100%+ utilization, uses existing summary without throwing', () => {
617
+ const maxContextTokens = 200_000;
618
+ const cachedSummary = 'Previously generated summary of the conversation';
619
+
620
+ // Context is at 105% (over budget)
621
+ const indexTokenCountMap: Record<string, number | undefined> = {
622
+ '0': 210_000, // system + everything
623
+ };
624
+
625
+ const utilization = getContextUtilization(indexTokenCountMap, 0, maxContextTokens);
626
+ expect(utilization).toBeGreaterThan(100);
627
+
628
+ // Even at 100%+, we use the existing cached summary — no error thrown
629
+ expect(cachedSummary).toBeTruthy();
630
+ // Compaction builds a windowed view — no messages deleted, no throwing
631
+ });
632
+ });
633
+
634
+ // ===========================================================================
635
+ // Context Compaction (Copilot-style: never delete messages)
636
+ // ===========================================================================
637
+
638
+ import { applyCalibration as _applyCalibration } from '@/utils/pruneCalibration';
639
+
640
+ describe('Context Compaction — Windowed View (no message deletion)', () => {
641
+ /**
642
+ * Simulates the compaction logic from Graph.ts without the full Graph instance.
643
+ * This tests the windowed-view algorithm directly.
644
+ */
645
+ function buildWindowedView(opts: {
646
+ messages: BaseMessage[];
647
+ indexTokenCountMap: Record<string, number | undefined>;
648
+ maxTokens: number;
649
+ summary?: string;
650
+ tokenCounter: TokenCounter;
651
+ }) {
652
+ const { messages, indexTokenCountMap, maxTokens, summary, tokenCounter } = opts;
653
+
654
+ const systemMsg = messages[0]?.getType() === 'system' ? messages[0] : null;
655
+ const systemTokens = systemMsg != null ? (indexTokenCountMap[0] ?? 0) : 0;
656
+ const summaryMsg = summary
657
+ ? new SystemMessage(`[Conversation Summary]\n${summary}`)
658
+ : null;
659
+ const summaryTokens = summaryMsg != null ? tokenCounter(summaryMsg) : 0;
660
+
661
+ const recentBudget = maxTokens - systemTokens - summaryTokens - 3;
662
+ const contentStart = systemMsg != null ? 1 : 0;
663
+ let usedTokens = 0;
664
+ let windowStart = messages.length;
665
+
666
+ for (let i = messages.length - 1; i >= contentStart; i--) {
667
+ const msgTokens = indexTokenCountMap[i] ?? 0;
668
+ if (usedTokens + msgTokens > recentBudget) break;
669
+ usedTokens += msgTokens;
670
+ windowStart = i;
671
+ }
672
+
673
+ // Don't split tool-call / tool-result pairs
674
+ while (
675
+ windowStart > contentStart &&
676
+ messages[windowStart]?.getType() === 'tool'
677
+ ) {
678
+ windowStart--;
679
+ usedTokens += indexTokenCountMap[windowStart] ?? 0;
680
+ }
681
+
682
+ const recentMessages = messages.slice(windowStart);
683
+ const compactedMessages = messages.slice(contentStart, windowStart);
684
+
685
+ const view: BaseMessage[] = [];
686
+ if (systemMsg) view.push(systemMsg);
687
+ if (summaryMsg) view.push(summaryMsg);
688
+ view.push(...recentMessages);
689
+
690
+ return { view, compactedMessages, recentMessages, usedTokens };
691
+ }
692
+
693
+ it('builds a windowed view without deleting any messages', () => {
694
+ const messages = buildConversation(20, 400); // system + 20 content msgs
695
+ const indexTokenCountMap: Record<string, number | undefined> = {};
696
+ for (let i = 0; i < messages.length; i++) {
697
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
698
+ }
699
+
700
+ const { view, compactedMessages, recentMessages } = buildWindowedView({
701
+ messages,
702
+ indexTokenCountMap,
703
+ maxTokens: 500, // small budget forces windowing
704
+ tokenCounter: simpleTokenCounter,
705
+ });
706
+
707
+ // View is smaller than original
708
+ expect(view.length).toBeLessThan(messages.length);
709
+ // But original messages array is untouched
710
+ expect(messages.length).toBe(21); // system + 20
711
+ // Compacted + recent = all non-system messages
712
+ expect(compactedMessages.length + recentMessages.length).toBe(20);
713
+ // View starts with system message
714
+ expect(view[0].getType()).toBe('system');
715
+ });
716
+
717
+ it('injects summary message covering compacted (windowed-out) messages', () => {
718
+ const messages = buildConversation(20, 400);
719
+ const indexTokenCountMap: Record<string, number | undefined> = {};
720
+ for (let i = 0; i < messages.length; i++) {
721
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
722
+ }
723
+
724
+ const summary = 'Summary of earlier conversation turns';
725
+ const { view, compactedMessages } = buildWindowedView({
726
+ messages,
727
+ indexTokenCountMap,
728
+ maxTokens: 600,
729
+ summary,
730
+ tokenCounter: simpleTokenCounter,
731
+ });
732
+
733
+ // Summary is injected after system message
734
+ expect(view[1].content).toContain('[Conversation Summary]');
735
+ expect(view[1].content).toContain(summary);
736
+ // There should be compacted messages behind the summary
737
+ expect(compactedMessages.length).toBeGreaterThan(0);
738
+ // Original array is unchanged
739
+ expect(messages.length).toBe(21);
740
+ });
741
+
742
+ it('includes all messages when budget is large enough (no compaction)', () => {
743
+ const messages = buildConversation(5, 100); // small conversation
744
+ const indexTokenCountMap: Record<string, number | undefined> = {};
745
+ for (let i = 0; i < messages.length; i++) {
746
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
747
+ }
748
+
749
+ const { view, compactedMessages } = buildWindowedView({
750
+ messages,
751
+ indexTokenCountMap,
752
+ maxTokens: 100_000, // huge budget
753
+ tokenCounter: simpleTokenCounter,
754
+ });
755
+
756
+ // All messages fit — no compaction
757
+ expect(view.length).toBe(messages.length);
758
+ expect(compactedMessages.length).toBe(0);
759
+ });
760
+
761
+ it('does not split tool-call / tool-result pairs at window boundary', () => {
762
+ const messages: BaseMessage[] = [
763
+ new SystemMessage('System'),
764
+ new HumanMessage('old question'),
765
+ new AIMessage('old answer'),
766
+ new HumanMessage('question about tool'),
767
+ new AIMessageChunk({
768
+ content: 'Let me search',
769
+ tool_calls: [{ id: 'tc_1', name: 'web_search', args: {} }],
770
+ }),
771
+ new ToolMessage({ content: 'Search results', tool_call_id: 'tc_1', name: 'web_search' }),
772
+ new AIMessage('Based on the search results...'),
773
+ new HumanMessage('latest question'),
774
+ new AIMessage('latest answer'),
775
+ ];
776
+ const indexTokenCountMap: Record<string, number | undefined> = {};
777
+ for (let i = 0; i < messages.length; i++) {
778
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
779
+ }
780
+
781
+ // Budget that would naturally cut between the AI tool-call and ToolMessage
782
+ // Force the window to start at the ToolMessage by making budget tight
783
+ const toolMsgIdx = 5; // ToolMessage index
784
+ let budgetUpToTool = 3; // priming tokens
785
+ for (let i = toolMsgIdx; i < messages.length; i++) {
786
+ budgetUpToTool += indexTokenCountMap[i] ?? 0;
787
+ }
788
+ // Budget includes ToolMessage but NOT the AI tool-call before it
789
+ // The algorithm should walk back to include the AI message too
790
+ const tightBudget = budgetUpToTool + (indexTokenCountMap[0] ?? 0) + 5;
791
+
792
+ const { view } = buildWindowedView({
793
+ messages,
794
+ indexTokenCountMap,
795
+ maxTokens: tightBudget,
796
+ tokenCounter: simpleTokenCounter,
797
+ });
798
+
799
+ // Verify no ToolMessage appears without its preceding AI message
800
+ for (let i = 0; i < view.length; i++) {
801
+ if (view[i].getType() === 'tool' && i > 0) {
802
+ // The message before a ToolMessage should be an AI message (the tool caller)
803
+ // or another ToolMessage (multi-tool scenario), or system
804
+ const prevType = view[i - 1].getType();
805
+ expect(['ai', 'tool', 'system']).toContain(prevType);
806
+ }
807
+ }
808
+ });
809
+
810
+ it('with summary, recent messages use remaining budget after summary tokens', () => {
811
+ const messages = buildConversation(20, 400);
812
+ const indexTokenCountMap: Record<string, number | undefined> = {};
813
+ for (let i = 0; i < messages.length; i++) {
814
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
815
+ }
816
+
817
+ // Large summary eats into the budget
818
+ const largeSummary = 'S'.repeat(1000); // ~250 tokens
819
+ const { view: viewWithSummary, recentMessages: recentWithSummary } = buildWindowedView({
820
+ messages,
821
+ indexTokenCountMap,
822
+ maxTokens: 800,
823
+ summary: largeSummary,
824
+ tokenCounter: simpleTokenCounter,
825
+ });
826
+
827
+ // Without summary — more recent messages fit
828
+ const { recentMessages: recentWithout } = buildWindowedView({
829
+ messages,
830
+ indexTokenCountMap,
831
+ maxTokens: 800,
832
+ tokenCounter: simpleTokenCounter,
833
+ });
834
+
835
+ // Summary takes budget, so fewer recent messages fit
836
+ expect(recentWithSummary.length).toBeLessThan(recentWithout.length);
837
+ });
838
+
839
+ it('original messages array is never mutated', () => {
840
+ const messages = buildConversation(15, 400);
841
+ const originalLength = messages.length;
842
+ const originalFirstContent = messages[0].content;
843
+ const originalLastContent = messages[messages.length - 1].content;
844
+ const indexTokenCountMap: Record<string, number | undefined> = {};
845
+ for (let i = 0; i < messages.length; i++) {
846
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
847
+ }
848
+
849
+ // Run compaction multiple times
850
+ for (let i = 0; i < 5; i++) {
851
+ buildWindowedView({
852
+ messages,
853
+ indexTokenCountMap,
854
+ maxTokens: 300,
855
+ summary: `Summary iteration ${i}`,
856
+ tokenCounter: simpleTokenCounter,
857
+ });
858
+ }
859
+
860
+ // Original array unchanged after 5 compaction runs
861
+ expect(messages.length).toBe(originalLength);
862
+ expect(messages[0].content).toBe(originalFirstContent);
863
+ expect(messages[messages.length - 1].content).toBe(originalLastContent);
864
+ });
865
+ });