recursive-llm-ts 4.9.0 → 5.0.0

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.
@@ -0,0 +1,384 @@
1
+ package rlm
2
+
3
+ import (
4
+ "reflect"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+ )
9
+
10
+ func episodeTestMsg(id, content string, tokens int, ts time.Time) *StoreMessage {
11
+ return &StoreMessage{
12
+ ID: id,
13
+ Role: RoleUser,
14
+ Content: content,
15
+ Tokens: tokens,
16
+ Timestamp: ts,
17
+ }
18
+ }
19
+
20
+ func TestEpisodeManager_NewDefaults(t *testing.T) {
21
+ m := NewEpisodeManager("sess-defaults", EpisodeConfig{})
22
+ if m == nil {
23
+ t.Fatal("NewEpisodeManager returned nil")
24
+ }
25
+
26
+ if m.config.MaxEpisodeTokens != 2000 {
27
+ t.Errorf("MaxEpisodeTokens = %d, want 2000", m.config.MaxEpisodeTokens)
28
+ }
29
+ if m.config.MaxEpisodeMessages != 20 {
30
+ t.Errorf("MaxEpisodeMessages = %d, want 20", m.config.MaxEpisodeMessages)
31
+ }
32
+ if m.config.TopicChangeThreshold != 0.5 {
33
+ t.Errorf("TopicChangeThreshold = %f, want 0.5", m.config.TopicChangeThreshold)
34
+ }
35
+ if !m.config.AutoCompactAfterClose {
36
+ t.Errorf("AutoCompactAfterClose = %v, want true", m.config.AutoCompactAfterClose)
37
+ }
38
+ }
39
+
40
+ func TestEpisodeManager_AddMessage(t *testing.T) {
41
+ m := NewEpisodeManager("sess-add", EpisodeConfig{
42
+ MaxEpisodeMessages: 100,
43
+ MaxEpisodeTokens: 10000,
44
+ TopicChangeThreshold: 0.5,
45
+ AutoCompactAfterClose: true,
46
+ })
47
+
48
+ ts1 := time.Now().Add(-2 * time.Minute)
49
+ ts2 := ts1.Add(1 * time.Minute)
50
+
51
+ m.AddMessage(episodeTestMsg("msg1", "Implement LCM episode manager tests", 7, ts1))
52
+ m.AddMessage(episodeTestMsg("msg2", "Verify episode metadata updates", 5, ts2))
53
+
54
+ ep := m.GetActiveEpisode()
55
+ if ep == nil {
56
+ t.Fatal("GetActiveEpisode returned nil")
57
+ }
58
+ if len(ep.MessageIDs) != 2 {
59
+ t.Fatalf("len(MessageIDs) = %d, want 2", len(ep.MessageIDs))
60
+ }
61
+ if ep.MessageIDs[0] != "msg1" || ep.MessageIDs[1] != "msg2" {
62
+ t.Errorf("MessageIDs = %v, want [msg1 msg2]", ep.MessageIDs)
63
+ }
64
+ if ep.Tokens != 12 {
65
+ t.Errorf("Tokens = %d, want 12", ep.Tokens)
66
+ }
67
+ if !ep.StartTime.Equal(ts1) {
68
+ t.Errorf("StartTime = %v, want %v", ep.StartTime, ts1)
69
+ }
70
+ if !ep.EndTime.Equal(ts2) {
71
+ t.Errorf("EndTime = %v, want %v", ep.EndTime, ts2)
72
+ }
73
+ if strings.TrimSpace(ep.Title) == "" {
74
+ t.Error("Title should be set from first message content")
75
+ }
76
+ if len(ep.Tags) == 0 {
77
+ t.Error("Tags should be set from first message content")
78
+ }
79
+ }
80
+
81
+ func TestEpisodeManager_AutoRotation(t *testing.T) {
82
+ m := NewEpisodeManager("sess-rotate", EpisodeConfig{
83
+ MaxEpisodeMessages: 3,
84
+ MaxEpisodeTokens: 10000,
85
+ TopicChangeThreshold: 0.5,
86
+ AutoCompactAfterClose: false,
87
+ })
88
+
89
+ base := time.Now()
90
+ m.AddMessage(episodeTestMsg("m1", "first", 1, base))
91
+ m.AddMessage(episodeTestMsg("m2", "second", 1, base.Add(time.Second)))
92
+ m.AddMessage(episodeTestMsg("m3", "third", 1, base.Add(2*time.Second)))
93
+ m.AddMessage(episodeTestMsg("m4", "fourth", 1, base.Add(3*time.Second)))
94
+
95
+ episodes := m.GetAllEpisodes()
96
+ if len(episodes) != 2 {
97
+ t.Fatalf("len(GetAllEpisodes()) = %d, want 2", len(episodes))
98
+ }
99
+
100
+ first := episodes[0]
101
+ second := episodes[1]
102
+ if len(first.MessageIDs) != 3 {
103
+ t.Errorf("first episode messages = %d, want 3", len(first.MessageIDs))
104
+ }
105
+ if len(second.MessageIDs) != 1 || second.MessageIDs[0] != "m4" {
106
+ t.Errorf("second episode MessageIDs = %v, want [m4]", second.MessageIDs)
107
+ }
108
+ if m.GetActiveEpisode() == nil || m.GetActiveEpisode().ID != second.ID {
109
+ t.Fatalf("active episode should be second episode")
110
+ }
111
+ if second.ParentEpisodeID != first.ID {
112
+ t.Errorf("ParentEpisodeID = %s, want %s", second.ParentEpisodeID, first.ID)
113
+ }
114
+ }
115
+
116
+ func TestEpisodeManager_CloseActiveEpisode(t *testing.T) {
117
+ m := NewEpisodeManager("sess-close", EpisodeConfig{})
118
+ msg := episodeTestMsg("m1", "close this episode", 4, time.Now())
119
+ m.AddMessage(msg)
120
+
121
+ ep := m.CloseActiveEpisode()
122
+ if ep == nil {
123
+ t.Fatal("CloseActiveEpisode returned nil")
124
+ }
125
+ if ep.Status != EpisodeCompacted {
126
+ t.Errorf("Status = %s, want %s", ep.Status, EpisodeCompacted)
127
+ }
128
+ if strings.TrimSpace(ep.Summary) == "" {
129
+ t.Error("Summary should be auto-generated when auto-compact is enabled")
130
+ }
131
+ if ep.EndTime.IsZero() {
132
+ t.Error("EndTime should be set")
133
+ }
134
+ if m.GetActiveEpisode() != nil {
135
+ t.Error("active episode should be nil after close")
136
+ }
137
+ }
138
+
139
+ func TestEpisodeManager_CompactEpisode(t *testing.T) {
140
+ m := NewEpisodeManager("sess-compact", EpisodeConfig{
141
+ MaxEpisodeTokens: 100,
142
+ MaxEpisodeMessages: 10,
143
+ TopicChangeThreshold: 0.5,
144
+ AutoCompactAfterClose: false,
145
+ })
146
+ m.AddMessage(episodeTestMsg("m1", "episode to compact", 3, time.Now()))
147
+ ep := m.CloseActiveEpisode()
148
+ if ep == nil {
149
+ t.Fatal("CloseActiveEpisode returned nil")
150
+ }
151
+
152
+ summary := "Concise summary of the episode"
153
+ if err := m.CompactEpisode(ep.ID, summary); err != nil {
154
+ t.Fatalf("CompactEpisode returned error: %v", err)
155
+ }
156
+
157
+ updated, ok := m.GetEpisode(ep.ID)
158
+ if !ok {
159
+ t.Fatalf("GetEpisode(%s) returned not found", ep.ID)
160
+ }
161
+ if updated.Summary != summary {
162
+ t.Errorf("Summary = %q, want %q", updated.Summary, summary)
163
+ }
164
+ if updated.SummaryTokens <= 0 {
165
+ t.Errorf("SummaryTokens = %d, want > 0", updated.SummaryTokens)
166
+ }
167
+ if updated.Status != EpisodeCompacted {
168
+ t.Errorf("Status = %s, want %s", updated.Status, EpisodeCompacted)
169
+ }
170
+ }
171
+
172
+ func TestEpisodeManager_CompactEpisode_NotFound(t *testing.T) {
173
+ m := NewEpisodeManager("sess-compact-missing", EpisodeConfig{})
174
+ err := m.CompactEpisode("ep_missing", "summary")
175
+ if err == nil {
176
+ t.Fatal("CompactEpisode expected error for missing episode")
177
+ }
178
+ if !strings.Contains(err.Error(), "episode not found") {
179
+ t.Errorf("error = %q, want to contain %q", err.Error(), "episode not found")
180
+ }
181
+ }
182
+
183
+ func TestEpisodeManager_CompactEpisode_EmptySummary(t *testing.T) {
184
+ m := NewEpisodeManager("sess-compact-empty", EpisodeConfig{
185
+ MaxEpisodeTokens: 100,
186
+ MaxEpisodeMessages: 10,
187
+ TopicChangeThreshold: 0.5,
188
+ AutoCompactAfterClose: false,
189
+ })
190
+ m.AddMessage(episodeTestMsg("m1", "episode to compact", 2, time.Now()))
191
+ ep := m.CloseActiveEpisode()
192
+ if ep == nil {
193
+ t.Fatal("CloseActiveEpisode returned nil")
194
+ }
195
+
196
+ err := m.CompactEpisode(ep.ID, " \n\t")
197
+ if err == nil {
198
+ t.Fatal("CompactEpisode expected error for empty summary")
199
+ }
200
+ if !strings.Contains(err.Error(), "summary cannot be empty") {
201
+ t.Errorf("error = %q, want to contain %q", err.Error(), "summary cannot be empty")
202
+ }
203
+ }
204
+
205
+ func TestEpisodeManager_GetEpisodesForContext(t *testing.T) {
206
+ m := NewEpisodeManager("sess-context", EpisodeConfig{
207
+ MaxEpisodeTokens: 10000,
208
+ MaxEpisodeMessages: 1,
209
+ TopicChangeThreshold: 0.5,
210
+ AutoCompactAfterClose: false,
211
+ })
212
+
213
+ now := time.Now()
214
+ m.AddMessage(episodeTestMsg("m1", "episode one", 10, now))
215
+ m.AddMessage(episodeTestMsg("m2", "episode two", 8, now.Add(time.Second)))
216
+ m.AddMessage(episodeTestMsg("m3", "episode three", 6, now.Add(2*time.Second)))
217
+
218
+ episodes := m.GetAllEpisodes()
219
+ if len(episodes) != 3 {
220
+ t.Fatalf("len(GetAllEpisodes()) = %d, want 3", len(episodes))
221
+ }
222
+
223
+ // Force deterministic costs for non-active episodes.
224
+ episodes[0].Status = EpisodeCompacted
225
+ episodes[0].SummaryTokens = 3
226
+ episodes[1].Status = EpisodeCompacted
227
+ episodes[1].SummaryTokens = 4
228
+
229
+ selected := m.GetEpisodesForContext(10)
230
+ if len(selected) != 2 {
231
+ t.Fatalf("len(GetEpisodesForContext(10)) = %d, want 2", len(selected))
232
+ }
233
+ if selected[0].ID != episodes[2].ID {
234
+ t.Errorf("selected[0] = %s, want active %s", selected[0].ID, episodes[2].ID)
235
+ }
236
+ if selected[1].ID != episodes[1].ID {
237
+ t.Errorf("selected[1] = %s, want %s", selected[1].ID, episodes[1].ID)
238
+ }
239
+
240
+ // Active episode should still be included even if it exceeds budget.
241
+ smallBudget := m.GetEpisodesForContext(5)
242
+ if len(smallBudget) != 1 {
243
+ t.Fatalf("len(GetEpisodesForContext(5)) = %d, want 1", len(smallBudget))
244
+ }
245
+ if smallBudget[0].ID != episodes[2].ID {
246
+ t.Errorf("smallBudget[0] = %s, want active %s", smallBudget[0].ID, episodes[2].ID)
247
+ }
248
+ }
249
+
250
+ func TestEpisodeManager_GetAllEpisodes(t *testing.T) {
251
+ m := NewEpisodeManager("sess-all", EpisodeConfig{
252
+ MaxEpisodeTokens: 10000,
253
+ MaxEpisodeMessages: 1,
254
+ TopicChangeThreshold: 0.5,
255
+ AutoCompactAfterClose: false,
256
+ })
257
+
258
+ now := time.Now()
259
+ m.AddMessage(episodeTestMsg("m1", "first", 1, now))
260
+ m.AddMessage(episodeTestMsg("m2", "second", 1, now.Add(time.Second)))
261
+ m.AddMessage(episodeTestMsg("m3", "third", 1, now.Add(2*time.Second)))
262
+
263
+ episodes := m.GetAllEpisodes()
264
+ if len(episodes) != 3 {
265
+ t.Fatalf("len(GetAllEpisodes()) = %d, want 3", len(episodes))
266
+ }
267
+
268
+ for i, ep := range episodes {
269
+ if len(ep.MessageIDs) != 1 {
270
+ t.Fatalf("episode %d should have 1 message, got %d", i, len(ep.MessageIDs))
271
+ }
272
+ wantMsgID := "m" + string(rune('1'+i))
273
+ if ep.MessageIDs[0] != wantMsgID {
274
+ t.Errorf("episode %d message ID = %s, want %s", i, ep.MessageIDs[0], wantMsgID)
275
+ }
276
+ }
277
+ }
278
+
279
+ func TestEpisodeManager_ParentChaining(t *testing.T) {
280
+ m := NewEpisodeManager("sess-parent", EpisodeConfig{
281
+ MaxEpisodeTokens: 10000,
282
+ MaxEpisodeMessages: 1,
283
+ TopicChangeThreshold: 0.5,
284
+ AutoCompactAfterClose: false,
285
+ })
286
+
287
+ now := time.Now()
288
+ m.AddMessage(episodeTestMsg("m1", "parent one", 1, now))
289
+ m.AddMessage(episodeTestMsg("m2", "parent two", 1, now.Add(time.Second)))
290
+ m.AddMessage(episodeTestMsg("m3", "parent three", 1, now.Add(2*time.Second)))
291
+
292
+ episodes := m.GetAllEpisodes()
293
+ if len(episodes) != 3 {
294
+ t.Fatalf("len(GetAllEpisodes()) = %d, want 3", len(episodes))
295
+ }
296
+
297
+ if episodes[0].ParentEpisodeID != "" {
298
+ t.Errorf("episodes[0].ParentEpisodeID = %q, want empty", episodes[0].ParentEpisodeID)
299
+ }
300
+ if episodes[1].ParentEpisodeID != episodes[0].ID {
301
+ t.Errorf("episodes[1].ParentEpisodeID = %q, want %q", episodes[1].ParentEpisodeID, episodes[0].ID)
302
+ }
303
+ if episodes[2].ParentEpisodeID != episodes[1].ID {
304
+ t.Errorf("episodes[2].ParentEpisodeID = %q, want %q", episodes[2].ParentEpisodeID, episodes[1].ID)
305
+ }
306
+ }
307
+
308
+ func TestEpisodeManager_NilMessage(t *testing.T) {
309
+ m := NewEpisodeManager("sess-nil", EpisodeConfig{})
310
+ m.AddMessage(nil)
311
+
312
+ if m.GetActiveEpisode() != nil {
313
+ t.Error("active episode should remain nil after AddMessage(nil)")
314
+ }
315
+ if len(m.GetAllEpisodes()) != 0 {
316
+ t.Errorf("len(GetAllEpisodes()) = %d, want 0", len(m.GetAllEpisodes()))
317
+ }
318
+ }
319
+
320
+ func TestBuildEpisodeTitle(t *testing.T) {
321
+ tests := []struct {
322
+ name string
323
+ content string
324
+ want string
325
+ }{
326
+ {
327
+ name: "empty content",
328
+ content: " ",
329
+ want: "Untitled Episode",
330
+ },
331
+ {
332
+ name: "short title",
333
+ content: "Investigate LCM regression",
334
+ want: "Investigate LCM regression",
335
+ },
336
+ {
337
+ name: "long title truncates to eight words",
338
+ content: "one two three four five six seven eight nine ten",
339
+ want: "one two three four five six seven eight",
340
+ },
341
+ }
342
+
343
+ for _, tt := range tests {
344
+ t.Run(tt.name, func(t *testing.T) {
345
+ got := buildEpisodeTitle(tt.content)
346
+ if got != tt.want {
347
+ t.Errorf("buildEpisodeTitle(%q) = %q, want %q", tt.content, got, tt.want)
348
+ }
349
+ })
350
+ }
351
+ }
352
+
353
+ func TestBuildEpisodeTags(t *testing.T) {
354
+ tests := []struct {
355
+ name string
356
+ content string
357
+ want []string
358
+ }{
359
+ {
360
+ name: "empty",
361
+ content: "",
362
+ want: nil,
363
+ },
364
+ {
365
+ name: "lowercase and punctuation stripping",
366
+ content: "Go, go! TEST test cases extra",
367
+ want: []string{"go", "test"},
368
+ },
369
+ {
370
+ name: "max three input words before cleanup",
371
+ content: "alpha beta gamma delta epsilon",
372
+ want: []string{"alpha", "beta", "gamma"},
373
+ },
374
+ }
375
+
376
+ for _, tt := range tests {
377
+ t.Run(tt.name, func(t *testing.T) {
378
+ got := buildEpisodeTags(tt.content)
379
+ if !reflect.DeepEqual(got, tt.want) {
380
+ t.Errorf("buildEpisodeTags(%q) = %v, want %v", tt.content, got, tt.want)
381
+ }
382
+ })
383
+ }
384
+ }