返回 DeepSeek-Reasonix
catalog_reducer_test.go
根目录 / internal / session / catalog_reducer_test.go
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(&current)
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
188 lines GO