| 1 | package session |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "reflect" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "reasonix/internal/provider" |
| 13 | ) |
| 14 | |
| 15 | func TestCatalogReducerMatchesCanonicalProjection(t *testing.T) { |
| 16 | r := catalogReducer{} |
| 17 | var commits []Commit |
| 18 | add := func(kind string, body any) { |
| 19 | t.Helper() |
| 20 | payload, err := json.Marshal(body) |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | sequence := uint64(len(commits) + 1) |
| 25 | commit := Commit{TurnID: "turn", FirstSequence: sequence, EventCount: 1, Events: []Event{{Kind: kind, Sequence: sequence, Payload: payload}}} |
| 26 | commits = append(commits, commit) |
| 27 | full, err := Project(commits) |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | if err := r.apply(commit); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | want, got := metadataFromProjection(Manifest{}, sequence, full), r.metadata(Manifest{}) |
| 35 | if !reflect.DeepEqual(got, want) { |
| 36 | t.Fatalf("after %s: got %+v want %+v", kind, got, want) |
| 37 | } |
| 38 | if len(r.state.Messages)+len(r.state.ModelMessages)+len(r.state.Turns)+len(r.state.ActiveTools)+len(r.state.Interactions) != 0 { |
| 39 | t.Fatal("body or authority state survived metadata reduction") |
| 40 | } |
| 41 | } |
| 42 | message := func(id, text string) provider.Message { |
| 43 | return provider.Message{ID: id, Role: provider.RoleUser, RawContent: text, Content: text} |
| 44 | } |
| 45 | add("legacy/import", map[string]any{"messages": []provider.Message{message("old", "old request")}, "modelRef": "old-model"}) |
| 46 | add("session/title", map[string]any{"title": "custom title"}) |
| 47 | add("session/config", map[string]any{"modelRef": "new-model", "modelIdentity": "identity"}) |
| 48 | add("turn/start", map[string]any{}) |
| 49 | add("message/complete", map[string]any{"message": message("second", "second request")}) |
| 50 | add("message/upsert", map[string]any{"message": message("old", "")}) |
| 51 | add("model/context-replace", map[string]any{"messages": []provider.Message{message("model", "not a preview")}}) |
| 52 | add("compaction", map[string]any{"messages": []provider.Message{message("compact", "not a preview either")}}) |
| 53 | add("turn/end", map[string]any{"status": "completed"}) |
| 54 | add("turn/end", map[string]any{"status": "completed"}) |
| 55 | add("history/replace", map[string]any{"messages": []provider.Message{message("x", "first"), message("x", "duplicate allowed by replacement"), message("y", "last")}}) |
| 56 | add("message/upsert", map[string]any{"message": message("x", "")}) |
| 57 | add("message/upsert", map[string]any{"message": provider.Message{ID: "y", Role: provider.RoleUser, Origin: provider.MessageOriginHost, Content: "host"}}) |
| 58 | add("history/replace", map[string]any{"messages": []provider.Message{}}) |
| 59 | for i := range 100 { |
| 60 | add("message/complete", map[string]any{"message": message(fmt.Sprint(i), strings.Repeat("body", 100))}) |
| 61 | add("message/upsert", map[string]any{"message": message(fmt.Sprint(i), "")}) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func TestCatalogReducerRejectsDuplicateCompleteAndMalformedPayload(t *testing.T) { |
| 66 | for _, kind := range []string{"message/complete", "session/config", "turn/end", "tool/result"} { |
| 67 | r := catalogReducer{} |
| 68 | if err := r.apply(Commit{Events: []Event{{Kind: kind, Payload: json.RawMessage(`{}`)}}}); err == nil { |
| 69 | t.Fatalf("accepted malformed %s", kind) |
| 70 | } |
| 71 | } |
| 72 | r := catalogReducer{} |
| 73 | commit := Commit{Events: []Event{{Kind: "message/complete", Payload: json.RawMessage(`{"message":{"id":"same","role":"user","content":"hello"}}`)}}} |
| 74 | if err := r.apply(commit); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if err := r.apply(commit); err == nil { |
| 78 | t.Fatal("accepted duplicate complete across commits") |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestCatalogResultSequenceAdvancesOnlyForVisibleAssistantResults(t *testing.T) { |
| 83 | assistant, _ := json.Marshal(map[string]any{"message": provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "done"}}) |
| 84 | commits := []Commit{ |
| 85 | {TurnID: "turn-1", FirstSequence: 1, EventCount: 1, Events: []Event{{Kind: "turn/start", Sequence: 1, Payload: json.RawMessage(`{}`)}}}, |
| 86 | {TurnID: "turn-1", FirstSequence: 2, EventCount: 1, Events: []Event{{Kind: "message/complete", Sequence: 2, Payload: assistant}}}, |
| 87 | {TurnID: "turn-1", FirstSequence: 3, EventCount: 1, Events: []Event{{Kind: "turn/end", Sequence: 3, Payload: json.RawMessage(`{"status":"completed"}`)}}}, |
| 88 | {FirstSequence: 4, EventCount: 1, Events: []Event{{Kind: "plan/state", Sequence: 4, Payload: json.RawMessage(`{"enabled":false}`)}}}, |
| 89 | {FirstSequence: 5, EventCount: 1, Events: []Event{{Kind: "session/title", Sequence: 5, Payload: json.RawMessage(`{"title":"renamed"}`)}}}, |
| 90 | } |
| 91 | r := catalogReducer{} |
| 92 | for _, commit := range commits { |
| 93 | if err := r.apply(commit); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | } |
| 97 | metadata := r.metadata(Manifest{}) |
| 98 | if metadata.ResultSequence != 3 { |
| 99 | t.Fatalf("result sequence = %d, want completed answer boundary 3", metadata.ResultSequence) |
| 100 | } |
| 101 | if metadata.Sequence != 5 { |
| 102 | t.Fatalf("event sequence = %d, want all events through 5", metadata.Sequence) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | type largeCatalogReader struct { |
| 107 | count int |
| 108 | baseline, peak uint64 |
| 109 | allocatedPeak uint64 |
| 110 | t *testing.T |
| 111 | } |
| 112 | |
| 113 | func (h *largeCatalogReader) Read(ctx context.Context, cursor uint64, limit int) (EventPage, error) { |
| 114 | if err := ctx.Err(); err != nil { |
| 115 | return EventPage{}, err |
| 116 | } |
| 117 | var current runtime.MemStats |
| 118 | runtime.ReadMemStats(¤t) |
| 119 | if current.HeapAlloc > h.baseline { |
| 120 | h.allocatedPeak = max(h.allocatedPeak, current.HeapAlloc-h.baseline) |
| 121 | } |
| 122 | if cursor%256 == 0 { |
| 123 | runtime.GC() |
| 124 | var stats runtime.MemStats |
| 125 | runtime.ReadMemStats(&stats) |
| 126 | if stats.HeapAlloc > h.baseline { |
| 127 | h.peak = max(h.peak, stats.HeapAlloc-h.baseline) |
| 128 | } |
| 129 | if h.peak > 32<<20 { |
| 130 | h.t.Fatalf("retained heap grew with message bodies: %.1f MiB", float64(h.peak)/(1<<20)) |
| 131 | } |
| 132 | } |
| 133 | page := EventPage{} |
| 134 | for i := int(cursor); i < min(int(cursor)+limit, h.count); i++ { |
| 135 | role := provider.RoleAssistant |
| 136 | if i == 0 { |
| 137 | role = provider.RoleUser |
| 138 | } |
| 139 | payload, _ := json.Marshal(map[string]any{"message": provider.Message{ID: fmt.Sprint(i), Role: role, Content: strings.Repeat("a", 64<<10)}}) |
| 140 | seq := uint64(i + 1) |
| 141 | page.Commits = append(page.Commits, Commit{FirstSequence: seq, EventCount: 1, Events: []Event{{Kind: "message/complete", Sequence: seq, Payload: payload}}}) |
| 142 | page.Next = seq |
| 143 | } |
| 144 | page.Truncated = int(page.Next) < h.count |
| 145 | return page, nil |
| 146 | } |
| 147 | |
| 148 | type streamingCatalogProbe struct { |
| 149 | pagedCatalogHandle |
| 150 | scans int |
| 151 | } |
| 152 | |
| 153 | func (*streamingCatalogProbe) Read(context.Context, uint64, int) (EventPage, error) { |
| 154 | panic("streaming catalog unexpectedly used sparse pages") |
| 155 | } |
| 156 | func (p *streamingCatalogProbe) scanCatalog(_ context.Context, apply func(Commit) error) error { |
| 157 | p.scans++ |
| 158 | return apply(Commit{Events: []Event{{Kind: "session/title", Sequence: 1, Payload: json.RawMessage(`{"title":"streamed"}`)}}}) |
| 159 | } |
| 160 | |
| 161 | func TestCatalogUsesSinglePassReaderWhenAvailable(t *testing.T) { |
| 162 | p := &streamingCatalogProbe{} |
| 163 | m, err := reduceCatalogMetadata(t.Context(), p, Manifest{}) |
| 164 | if err != nil || m.Title != "streamed" || p.scans != 1 { |
| 165 | t.Fatalf("metadata=%+v scans=%d err=%v", m, p.scans, err) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func TestCatalogReducerLargeHistoryRetainedHeap(t *testing.T) { |
| 170 | for _, count := range []int{512, 8192, 8192} { |
| 171 | runtime.GC() |
| 172 | var stats runtime.MemStats |
| 173 | runtime.ReadMemStats(&stats) |
| 174 | h := &largeCatalogReader{count: count, baseline: stats.HeapAlloc, t: t} |
| 175 | m, err := reduceCatalogMetadata(t.Context(), h, Manifest{}) |
| 176 | if err != nil || m.Sequence != uint64(count) || m.Preview == "" { |
| 177 | t.Fatalf("metadata=%+v err=%v", m, err) |
| 178 | } |
| 179 | t.Logf("%d messages / %d MiB text: sampled retained heap %.2f MiB, sampled heap peak %.2f MiB", count, count*64/1024, float64(h.peak)/(1<<20), float64(h.allocatedPeak)/(1<<20)) |
| 180 | runtime.GC() |
| 181 | runtime.ReadMemStats(&stats) |
| 182 | if stats.HeapAlloc > h.baseline+8<<20 { |
| 183 | t.Fatalf("completed rebuild retained %.2f MiB", float64(stats.HeapAlloc-h.baseline)/(1<<20)) |
| 184 | } |
| 185 | runtime.KeepAlive(m) |
| 186 | } |
| 187 | } |
| 188 |