@illuma-ai/agents 1.1.2 → 1.1.4

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.
@@ -627,7 +627,326 @@ describe('Proactive Summarization — Context Pressure', () => {
627
627
 
628
628
  // Even at 100%+, we use the existing cached summary — no error thrown
629
629
  expect(cachedSummary).toBeTruthy();
630
- // Pruning will remove oldest messages to fit, and inject cached summary
631
- // The key: no blocking, no throwing, just graceful degradation
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
+ import { COMPACTION_RECENT_ROUNDS } from '@/common/constants';
640
+
641
+ describe('Context Compaction — Windowed View (no message deletion)', () => {
642
+ /**
643
+ * Simulates the compaction logic from Graph.ts without the full Graph instance.
644
+ * Mirrors the two modes:
645
+ * A) No summary → fill budget with as many recent messages as fit
646
+ * B) Summary exists → keep last COMPACTION_RECENT_ROUNDS rounds only
647
+ */
648
+ function buildWindowedView(opts: {
649
+ messages: BaseMessage[];
650
+ indexTokenCountMap: Record<string, number | undefined>;
651
+ maxTokens: number;
652
+ summary?: string;
653
+ tokenCounter: TokenCounter;
654
+ }) {
655
+ const { messages, indexTokenCountMap, maxTokens, summary, tokenCounter } = opts;
656
+
657
+ const systemMsg = messages[0]?.getType() === 'system' ? messages[0] : null;
658
+ const systemTokens = systemMsg != null ? (indexTokenCountMap[0] ?? 0) : 0;
659
+ const summaryMsg = summary
660
+ ? new SystemMessage(`[Conversation Summary]\n${summary}`)
661
+ : null;
662
+ const summaryTokens = summaryMsg != null ? tokenCounter(summaryMsg) : 0;
663
+
664
+ const recentBudget = maxTokens - systemTokens - summaryTokens - 3;
665
+ const contentStart = systemMsg != null ? 1 : 0;
666
+ let usedTokens = 0;
667
+ let windowStart = messages.length;
668
+
669
+ if (!summary) {
670
+ // Mode A: No summary — fill budget
671
+ for (let i = messages.length - 1; i >= contentStart; i--) {
672
+ const msgTokens = indexTokenCountMap[i] ?? 0;
673
+ if (usedTokens + msgTokens > recentBudget) break;
674
+ usedTokens += msgTokens;
675
+ windowStart = i;
676
+ }
677
+ } else {
678
+ // Mode B: Summary exists — keep last N rounds
679
+ let roundsSeen = 0;
680
+ for (let i = messages.length - 1; i >= contentStart; i--) {
681
+ const msgType = messages[i]?.getType();
682
+ const msgTokens = indexTokenCountMap[i] ?? 0;
683
+ if (usedTokens + msgTokens > recentBudget) break;
684
+ usedTokens += msgTokens;
685
+ windowStart = i;
686
+ if (msgType === 'human') {
687
+ roundsSeen++;
688
+ if (roundsSeen >= COMPACTION_RECENT_ROUNDS) break;
689
+ }
690
+ }
691
+ }
692
+
693
+ // Don't split tool-call / tool-result pairs
694
+ while (
695
+ windowStart > contentStart &&
696
+ messages[windowStart]?.getType() === 'tool'
697
+ ) {
698
+ windowStart--;
699
+ usedTokens += indexTokenCountMap[windowStart] ?? 0;
700
+ }
701
+
702
+ const recentMessages = messages.slice(windowStart);
703
+ const compactedMessages = messages.slice(contentStart, windowStart);
704
+
705
+ const view: BaseMessage[] = [];
706
+ if (systemMsg) view.push(systemMsg);
707
+ if (summaryMsg) view.push(summaryMsg);
708
+ view.push(...recentMessages);
709
+
710
+ return { view, compactedMessages, recentMessages, usedTokens };
711
+ }
712
+
713
+ it('builds a windowed view without deleting any messages', () => {
714
+ const messages = buildConversation(20, 400); // system + 20 content msgs
715
+ const indexTokenCountMap: Record<string, number | undefined> = {};
716
+ for (let i = 0; i < messages.length; i++) {
717
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
718
+ }
719
+
720
+ const { view, compactedMessages, recentMessages } = buildWindowedView({
721
+ messages,
722
+ indexTokenCountMap,
723
+ maxTokens: 500, // small budget forces windowing
724
+ tokenCounter: simpleTokenCounter,
725
+ });
726
+
727
+ // View is smaller than original
728
+ expect(view.length).toBeLessThan(messages.length);
729
+ // But original messages array is untouched
730
+ expect(messages.length).toBe(21); // system + 20
731
+ // Compacted + recent = all non-system messages
732
+ expect(compactedMessages.length + recentMessages.length).toBe(20);
733
+ // View starts with system message
734
+ expect(view[0].getType()).toBe('system');
735
+ });
736
+
737
+ it('injects summary message covering compacted (windowed-out) messages', () => {
738
+ const messages = buildConversation(20, 400);
739
+ const indexTokenCountMap: Record<string, number | undefined> = {};
740
+ for (let i = 0; i < messages.length; i++) {
741
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
742
+ }
743
+
744
+ const summary = 'Summary of earlier conversation turns';
745
+ const { view, compactedMessages } = buildWindowedView({
746
+ messages,
747
+ indexTokenCountMap,
748
+ maxTokens: 600,
749
+ summary,
750
+ tokenCounter: simpleTokenCounter,
751
+ });
752
+
753
+ // Summary is injected after system message
754
+ expect(view[1].content).toContain('[Conversation Summary]');
755
+ expect(view[1].content).toContain(summary);
756
+ // There should be compacted messages behind the summary
757
+ expect(compactedMessages.length).toBeGreaterThan(0);
758
+ // Original array is unchanged
759
+ expect(messages.length).toBe(21);
760
+ });
761
+
762
+ it('includes all messages when budget is large enough (no compaction)', () => {
763
+ const messages = buildConversation(5, 100); // small conversation
764
+ const indexTokenCountMap: Record<string, number | undefined> = {};
765
+ for (let i = 0; i < messages.length; i++) {
766
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
767
+ }
768
+
769
+ const { view, compactedMessages } = buildWindowedView({
770
+ messages,
771
+ indexTokenCountMap,
772
+ maxTokens: 100_000, // huge budget
773
+ tokenCounter: simpleTokenCounter,
774
+ });
775
+
776
+ // All messages fit — no compaction
777
+ expect(view.length).toBe(messages.length);
778
+ expect(compactedMessages.length).toBe(0);
779
+ });
780
+
781
+ it('does not split tool-call / tool-result pairs at window boundary', () => {
782
+ const messages: BaseMessage[] = [
783
+ new SystemMessage('System'),
784
+ new HumanMessage('old question'),
785
+ new AIMessage('old answer'),
786
+ new HumanMessage('question about tool'),
787
+ new AIMessageChunk({
788
+ content: 'Let me search',
789
+ tool_calls: [{ id: 'tc_1', name: 'web_search', args: {} }],
790
+ }),
791
+ new ToolMessage({ content: 'Search results', tool_call_id: 'tc_1', name: 'web_search' }),
792
+ new AIMessage('Based on the search results...'),
793
+ new HumanMessage('latest question'),
794
+ new AIMessage('latest answer'),
795
+ ];
796
+ const indexTokenCountMap: Record<string, number | undefined> = {};
797
+ for (let i = 0; i < messages.length; i++) {
798
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
799
+ }
800
+
801
+ // Budget that would naturally cut between the AI tool-call and ToolMessage
802
+ // Force the window to start at the ToolMessage by making budget tight
803
+ const toolMsgIdx = 5; // ToolMessage index
804
+ let budgetUpToTool = 3; // priming tokens
805
+ for (let i = toolMsgIdx; i < messages.length; i++) {
806
+ budgetUpToTool += indexTokenCountMap[i] ?? 0;
807
+ }
808
+ // Budget includes ToolMessage but NOT the AI tool-call before it
809
+ // The algorithm should walk back to include the AI message too
810
+ const tightBudget = budgetUpToTool + (indexTokenCountMap[0] ?? 0) + 5;
811
+
812
+ const { view } = buildWindowedView({
813
+ messages,
814
+ indexTokenCountMap,
815
+ maxTokens: tightBudget,
816
+ tokenCounter: simpleTokenCounter,
817
+ });
818
+
819
+ // Verify no ToolMessage appears without its preceding AI message
820
+ for (let i = 0; i < view.length; i++) {
821
+ if (view[i].getType() === 'tool' && i > 0) {
822
+ // The message before a ToolMessage should be an AI message (the tool caller)
823
+ // or another ToolMessage (multi-tool scenario), or system
824
+ const prevType = view[i - 1].getType();
825
+ expect(['ai', 'tool', 'system']).toContain(prevType);
826
+ }
827
+ }
828
+ });
829
+
830
+ it('with summary, recent messages use remaining budget after summary tokens', () => {
831
+ const messages = buildConversation(20, 400);
832
+ const indexTokenCountMap: Record<string, number | undefined> = {};
833
+ for (let i = 0; i < messages.length; i++) {
834
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
835
+ }
836
+
837
+ // Large summary eats into the budget
838
+ const largeSummary = 'S'.repeat(1000); // ~250 tokens
839
+ const { view: viewWithSummary, recentMessages: recentWithSummary } = buildWindowedView({
840
+ messages,
841
+ indexTokenCountMap,
842
+ maxTokens: 800,
843
+ summary: largeSummary,
844
+ tokenCounter: simpleTokenCounter,
845
+ });
846
+
847
+ // Without summary — more recent messages fit
848
+ const { recentMessages: recentWithout } = buildWindowedView({
849
+ messages,
850
+ indexTokenCountMap,
851
+ maxTokens: 800,
852
+ tokenCounter: simpleTokenCounter,
853
+ });
854
+
855
+ // Summary takes budget, so fewer recent messages fit
856
+ expect(recentWithSummary.length).toBeLessThan(recentWithout.length);
857
+ });
858
+
859
+ it('with summary, limits window to last 2 rounds (not budget-filling)', () => {
860
+ // 20 messages = 10 rounds. With summary, should only keep last 2 rounds (4 msgs).
861
+ const messages: BaseMessage[] = [
862
+ new SystemMessage('System prompt'),
863
+ ];
864
+ for (let i = 0; i < 20; i++) {
865
+ messages.push(
866
+ i % 2 === 0
867
+ ? new HumanMessage(`User question ${i / 2}`)
868
+ : new AIMessage(`AI answer ${(i - 1) / 2}`)
869
+ );
870
+ }
871
+ const indexTokenCountMap: Record<string, number | undefined> = {};
872
+ for (let i = 0; i < messages.length; i++) {
873
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
874
+ }
875
+
876
+ const { view, recentMessages, compactedMessages } = buildWindowedView({
877
+ messages,
878
+ indexTokenCountMap,
879
+ maxTokens: 100_000, // huge budget — would fit everything
880
+ summary: 'Summary of earlier conversation',
881
+ tokenCounter: simpleTokenCounter,
882
+ });
883
+
884
+ // Despite huge budget, only last 2 rounds kept (4 content msgs: H+A+H+A)
885
+ // Plus possible trailing messages in the last round
886
+ expect(recentMessages.length).toBeLessThanOrEqual(5); // 2 rounds + maybe 1 trailing
887
+ expect(recentMessages.length).toBeGreaterThanOrEqual(4); // at least 2 full rounds
888
+
889
+ // Most messages are compacted behind the summary
890
+ expect(compactedMessages.length).toBeGreaterThan(10);
891
+
892
+ // View = system + summary + recent window
893
+ expect(view[0].getType()).toBe('system');
894
+ expect(view[1].content).toContain('[Conversation Summary]');
895
+ });
896
+
897
+ it('without summary, fills budget (no round limit)', () => {
898
+ const messages = buildConversation(20, 100); // small messages
899
+ const indexTokenCountMap: Record<string, number | undefined> = {};
900
+ for (let i = 0; i < messages.length; i++) {
901
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
902
+ }
903
+
904
+ const { recentMessages: withoutSummary } = buildWindowedView({
905
+ messages,
906
+ indexTokenCountMap,
907
+ maxTokens: 100_000, // huge budget
908
+ tokenCounter: simpleTokenCounter,
909
+ // no summary → mode A
910
+ });
911
+
912
+ const { recentMessages: withSummary } = buildWindowedView({
913
+ messages,
914
+ indexTokenCountMap,
915
+ maxTokens: 100_000,
916
+ summary: 'Summary exists',
917
+ tokenCounter: simpleTokenCounter,
918
+ });
919
+
920
+ // Without summary: all messages included (budget-filling mode)
921
+ expect(withoutSummary.length).toBe(20); // all content messages
922
+ // With summary: only last 2 rounds
923
+ expect(withSummary.length).toBeLessThan(withoutSummary.length);
924
+ });
925
+
926
+ it('original messages array is never mutated', () => {
927
+ const messages = buildConversation(15, 400);
928
+ const originalLength = messages.length;
929
+ const originalFirstContent = messages[0].content;
930
+ const originalLastContent = messages[messages.length - 1].content;
931
+ const indexTokenCountMap: Record<string, number | undefined> = {};
932
+ for (let i = 0; i < messages.length; i++) {
933
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
934
+ }
935
+
936
+ // Run compaction multiple times
937
+ for (let i = 0; i < 5; i++) {
938
+ buildWindowedView({
939
+ messages,
940
+ indexTokenCountMap,
941
+ maxTokens: 300,
942
+ summary: `Summary iteration ${i}`,
943
+ tokenCounter: simpleTokenCounter,
944
+ });
945
+ }
946
+
947
+ // Original array unchanged after 5 compaction runs
948
+ expect(messages.length).toBe(originalLength);
949
+ expect(messages[0].content).toBe(originalFirstContent);
950
+ expect(messages[messages.length - 1].content).toBe(originalLastContent);
632
951
  });
633
952
  });