| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "reflect" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/event" |
| 11 | "reasonix/internal/provider" |
| 12 | "reasonix/internal/sessioncontext" |
| 13 | ) |
| 14 | |
| 15 | func TestManualCompactFindsHistoryBehindFixedContext(t *testing.T) { |
| 16 | for _, window := range []int{0, 128_000} { |
| 17 | name := "configured-window" |
| 18 | if window == 0 { |
| 19 | name = "automatic-compaction-disabled" |
| 20 | } |
| 21 | t.Run(name, func(t *testing.T) { |
| 22 | prov := &fakeProvider{reply: "- Completed the requested work."} |
| 23 | sess := NewSession(strings.Repeat("stable system instructions ", 500)) |
| 24 | contextMessage := HostGeneratedUserMessage(sessioncontext.Build(sessioncontext.Sections{Workspace: strings.Repeat("workspace instructions ", 500)}).Content) |
| 25 | sess.Add(contextMessage) |
| 26 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "Implement the requested change."}) |
| 27 | sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("Completed implementation and verification. ", 50)}) |
| 28 | a := New(prov, nil, sess, Options{ContextWindow: window}, event.Discard) |
| 29 | canonical := append([]provider.Message(nil), sess.Messages...) |
| 30 | if err := a.PrepareContext(t.Context()); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | if len(prov.got) != 0 { |
| 34 | t.Fatal("automatic maintenance compacted below its threshold") |
| 35 | } |
| 36 | if err := a.CompactNow(t.Context(), "retain decisions"); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | if len(prov.got) == 0 || a.currentProjectionVersion() == 0 { |
| 40 | t.Fatal("manual compaction reported no history for a completed conversation") |
| 41 | } |
| 42 | if !reflect.DeepEqual(canonical, sess.Messages) { |
| 43 | t.Fatal("manual compaction changed the canonical transcript") |
| 44 | } |
| 45 | visible := a.modelVisibleMessages() |
| 46 | if visible[0].Content != canonical[0].Content { |
| 47 | t.Fatal("system prefix changed") |
| 48 | } |
| 49 | foundContext := false |
| 50 | for _, msg := range visible { |
| 51 | foundContext = foundContext || msg.Content == contextMessage.Content |
| 52 | } |
| 53 | if !foundContext { |
| 54 | t.Fatal("workspace context was lost") |
| 55 | } |
| 56 | }) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | func TestManualCompactWithoutWindowRecoversOverflow(t *testing.T) { |
| 61 | for _, numbered := range []bool{false, true} { |
| 62 | t.Run(fmt.Sprintf("numbered=%v", numbered), func(t *testing.T) { |
| 63 | prov := &denseTokenizerProvider{window: 20_000, unnumberedReplay: !numbered} |
| 64 | turns := 4 // Replay-only rejection; the transcript representation fits. |
| 65 | if numbered { |
| 66 | turns = 16 // Learn the physical window and split the oversized history. |
| 67 | } |
| 68 | a := New(prov, nil, longASCIISession(turns), Options{}, event.Discard) |
| 69 | if err := a.CompactNow(t.Context(), "retain decisions"); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | if prov.overflows == 0 || len(prov.requests) < 2 || a.currentProjectionVersion() == 0 || !hasCompactionSummary(a.modelVisibleMessages()) { |
| 73 | t.Fatalf("overflow recovery did not install a summary: calls=%d overflows=%d", len(prov.requests), prov.overflows) |
| 74 | } |
| 75 | if numbered && a.ContextUsedTokens() >= a.hardInputCeiling() { |
| 76 | t.Fatal("manual rescue left the context above the learned ceiling") |
| 77 | } |
| 78 | calls := len(prov.requests) |
| 79 | for range 30 { |
| 80 | a.sess.conversation.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("continue ", 1000)}) |
| 81 | } |
| 82 | if err := a.PrepareContext(t.Context()); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if a.ContextWindow() != 0 || len(prov.requests) != calls { |
| 86 | t.Fatal("manual recovery re-enabled automatic compaction") |
| 87 | } |
| 88 | }) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestManualCompactWithoutWindowReportsSummaryFailure(t *testing.T) { |
| 93 | want := errors.New("summary service unavailable") |
| 94 | prov := &fakeProvider{streamErr: want} |
| 95 | a := New(prov, nil, longASCIISession(4), Options{}, event.Discard) |
| 96 | if err := a.CompactNow(t.Context(), ""); !errors.Is(err, want) { |
| 97 | t.Fatalf("error = %v, want original summary error", err) |
| 98 | } |
| 99 | if a.currentProjectionVersion() != 0 { |
| 100 | t.Fatal("unknown ceiling caused lossy truncation after a summary failure") |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestManualCompactWithoutWindowKeepsEmptyAndActiveHistory(t *testing.T) { |
| 105 | for _, active := range []bool{false, true} { |
| 106 | prov := &fakeProvider{reply: "- Summary."} |
| 107 | sess := NewSession("system") |
| 108 | a := New(prov, nil, sess, Options{}, event.Discard) |
| 109 | if active { |
| 110 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "Keep the active request.", CreatedAt: 42}) |
| 111 | sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("work in progress ", 100)}) |
| 112 | a.activeTurnCreatedAt.Store(42) |
| 113 | } |
| 114 | if err := a.CompactNow(t.Context(), ""); err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | if len(prov.got) != 0 || a.currentProjectionVersion() != 0 { |
| 118 | t.Fatalf("active=%v: compacted empty or active history", active) |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 |