recursive-llm-ts 4.8.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,313 @@
1
+ package rlm
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ "sync"
7
+ "time"
8
+ )
9
+
10
+ // EpisodeStatus describes lifecycle state for an episode.
11
+ type EpisodeStatus string
12
+
13
+ const (
14
+ // EpisodeActive is currently accumulating messages.
15
+ EpisodeActive EpisodeStatus = "active"
16
+ // EpisodeCompacted has a generated summary while messages remain available.
17
+ EpisodeCompacted EpisodeStatus = "compacted"
18
+ // EpisodeArchived is deeply compressed and represented by summary in active context.
19
+ EpisodeArchived EpisodeStatus = "archived"
20
+ )
21
+
22
+ // Episode is a coherent interaction unit that groups related messages.
23
+ type Episode struct {
24
+ ID string `json:"id"`
25
+ Title string `json:"title"`
26
+ MessageIDs []string `json:"message_ids"`
27
+ StartTime time.Time `json:"start_time"`
28
+ EndTime time.Time `json:"end_time"`
29
+ Tokens int `json:"tokens"`
30
+ Summary string `json:"summary,omitempty"`
31
+ SummaryTokens int `json:"summary_tokens,omitempty"`
32
+ Status EpisodeStatus `json:"status"`
33
+ Tags []string `json:"tags,omitempty"`
34
+ ParentEpisodeID string `json:"parent_episode_id,omitempty"`
35
+ }
36
+
37
+ // EpisodeConfig controls episode boundaries and behavior.
38
+ type EpisodeConfig struct {
39
+ MaxEpisodeTokens int
40
+ MaxEpisodeMessages int
41
+ TopicChangeThreshold float64
42
+ AutoCompactAfterClose bool
43
+ }
44
+
45
+ // EpisodeManager manages episode creation, compaction, and retrieval.
46
+ type EpisodeManager struct {
47
+ mu sync.RWMutex
48
+ episodes map[string]*Episode
49
+ episodeSeq []*Episode
50
+ activeEpisode *Episode
51
+ nextID int
52
+ sessionID string
53
+ config EpisodeConfig
54
+ }
55
+
56
+ // NewEpisodeManager creates a manager with defaults applied.
57
+ func NewEpisodeManager(sessionID string, config EpisodeConfig) *EpisodeManager {
58
+ zeroConfig := config == (EpisodeConfig{})
59
+
60
+ if config.MaxEpisodeTokens <= 0 {
61
+ config.MaxEpisodeTokens = 2000
62
+ }
63
+ if config.MaxEpisodeMessages <= 0 {
64
+ config.MaxEpisodeMessages = 20
65
+ }
66
+ if config.TopicChangeThreshold <= 0 || config.TopicChangeThreshold > 1 {
67
+ config.TopicChangeThreshold = 0.5
68
+ }
69
+ if zeroConfig {
70
+ config.AutoCompactAfterClose = true
71
+ }
72
+
73
+ return &EpisodeManager{
74
+ episodes: make(map[string]*Episode),
75
+ episodeSeq: make([]*Episode, 0),
76
+ sessionID: sessionID,
77
+ config: config,
78
+ }
79
+ }
80
+
81
+ // AddMessage adds a message into the active episode, rotating when needed.
82
+ func (m *EpisodeManager) AddMessage(msg *StoreMessage) {
83
+ if msg == nil {
84
+ return
85
+ }
86
+
87
+ m.mu.Lock()
88
+ defer m.mu.Unlock()
89
+
90
+ if m.activeEpisode == nil {
91
+ m.activeEpisode = m.newEpisodeLocked("")
92
+ }
93
+
94
+ if m.shouldCloseEpisodeLocked() {
95
+ closed := m.closeActiveEpisodeLocked()
96
+ parentID := ""
97
+ if closed != nil {
98
+ parentID = closed.ID
99
+ }
100
+ m.activeEpisode = m.newEpisodeLocked(parentID)
101
+ }
102
+
103
+ ep := m.activeEpisode
104
+ ep.MessageIDs = append(ep.MessageIDs, msg.ID)
105
+ ep.Tokens += msg.Tokens
106
+ if ep.StartTime.IsZero() {
107
+ ep.StartTime = msg.Timestamp
108
+ }
109
+ ep.EndTime = msg.Timestamp
110
+ if strings.TrimSpace(ep.Title) == "" {
111
+ ep.Title = buildEpisodeTitle(msg.Content)
112
+ }
113
+ if len(ep.Tags) == 0 {
114
+ ep.Tags = buildEpisodeTags(msg.Content)
115
+ }
116
+ }
117
+
118
+ // CloseActiveEpisode closes the current active episode.
119
+ func (m *EpisodeManager) CloseActiveEpisode() *Episode {
120
+ m.mu.Lock()
121
+ defer m.mu.Unlock()
122
+ return m.closeActiveEpisodeLocked()
123
+ }
124
+
125
+ // GetEpisode retrieves an episode by id.
126
+ func (m *EpisodeManager) GetEpisode(id string) (*Episode, bool) {
127
+ m.mu.RLock()
128
+ defer m.mu.RUnlock()
129
+ ep, ok := m.episodes[id]
130
+ return ep, ok
131
+ }
132
+
133
+ // GetAllEpisodes returns all episodes in chronological order.
134
+ func (m *EpisodeManager) GetAllEpisodes() []*Episode {
135
+ m.mu.RLock()
136
+ defer m.mu.RUnlock()
137
+ out := make([]*Episode, len(m.episodeSeq))
138
+ copy(out, m.episodeSeq)
139
+ return out
140
+ }
141
+
142
+ // GetActiveEpisode returns the currently active episode.
143
+ func (m *EpisodeManager) GetActiveEpisode() *Episode {
144
+ m.mu.RLock()
145
+ defer m.mu.RUnlock()
146
+ return m.activeEpisode
147
+ }
148
+
149
+ // CompactEpisode compresses an episode into a summary.
150
+ func (m *EpisodeManager) CompactEpisode(episodeID string, summary string) error {
151
+ m.mu.Lock()
152
+ defer m.mu.Unlock()
153
+
154
+ ep, ok := m.episodes[episodeID]
155
+ if !ok {
156
+ return fmt.Errorf("episode not found: %s", episodeID)
157
+ }
158
+
159
+ summary = strings.TrimSpace(summary)
160
+ if summary == "" {
161
+ return fmt.Errorf("summary cannot be empty")
162
+ }
163
+
164
+ ep.Summary = summary
165
+ ep.SummaryTokens = EstimateTokens(summary)
166
+ ep.Status = EpisodeCompacted
167
+ if strings.TrimSpace(ep.Title) == "" {
168
+ ep.Title = buildEpisodeTitle(summary)
169
+ }
170
+ if len(ep.Tags) == 0 {
171
+ ep.Tags = buildEpisodeTags(summary)
172
+ }
173
+
174
+ return nil
175
+ }
176
+
177
+ // GetEpisodesForContext returns reverse-chronological episodes within budget.
178
+ // Active episode is always included when present.
179
+ func (m *EpisodeManager) GetEpisodesForContext(tokenBudget int) []*Episode {
180
+ m.mu.RLock()
181
+ defer m.mu.RUnlock()
182
+
183
+ if tokenBudget < 0 {
184
+ tokenBudget = 0
185
+ }
186
+
187
+ result := make([]*Episode, 0)
188
+ included := make(map[string]bool)
189
+ remaining := tokenBudget
190
+
191
+ if m.activeEpisode != nil {
192
+ result = append(result, m.activeEpisode)
193
+ included[m.activeEpisode.ID] = true
194
+ remaining -= m.activeEpisode.Tokens
195
+ if remaining <= 0 {
196
+ return result
197
+ }
198
+ }
199
+
200
+ for i := len(m.episodeSeq) - 1; i >= 0; i-- {
201
+ ep := m.episodeSeq[i]
202
+ if ep == nil || included[ep.ID] {
203
+ continue
204
+ }
205
+
206
+ cost := ep.Tokens
207
+ if ep.Status != EpisodeActive {
208
+ if ep.SummaryTokens > 0 {
209
+ cost = ep.SummaryTokens
210
+ }
211
+ }
212
+
213
+ if cost > remaining {
214
+ continue
215
+ }
216
+
217
+ result = append(result, ep)
218
+ remaining -= cost
219
+ if remaining <= 0 {
220
+ break
221
+ }
222
+ }
223
+
224
+ return result
225
+ }
226
+
227
+ func (m *EpisodeManager) shouldCloseEpisodeLocked() bool {
228
+ if m.activeEpisode == nil {
229
+ return false
230
+ }
231
+ if m.config.MaxEpisodeTokens > 0 && m.activeEpisode.Tokens >= m.config.MaxEpisodeTokens {
232
+ return true
233
+ }
234
+ if m.config.MaxEpisodeMessages > 0 && len(m.activeEpisode.MessageIDs) >= m.config.MaxEpisodeMessages {
235
+ return true
236
+ }
237
+ return false
238
+ }
239
+
240
+ func (m *EpisodeManager) closeActiveEpisodeLocked() *Episode {
241
+ if m.activeEpisode == nil {
242
+ return nil
243
+ }
244
+
245
+ ep := m.activeEpisode
246
+ if ep.EndTime.IsZero() {
247
+ ep.EndTime = time.Now()
248
+ }
249
+ if ep.Status == EpisodeActive && m.config.AutoCompactAfterClose {
250
+ if strings.TrimSpace(ep.Summary) == "" {
251
+ ep.Summary = fmt.Sprintf("Episode %s (%d messages)", ep.ID, len(ep.MessageIDs))
252
+ }
253
+ ep.SummaryTokens = EstimateTokens(ep.Summary)
254
+ ep.Status = EpisodeCompacted
255
+ }
256
+
257
+ m.activeEpisode = nil
258
+ return ep
259
+ }
260
+
261
+ func (m *EpisodeManager) newEpisodeLocked(parentEpisodeID string) *Episode {
262
+ m.nextID++
263
+ ep := &Episode{
264
+ ID: fmt.Sprintf("ep_%s_%d", m.sessionID, m.nextID),
265
+ Title: "",
266
+ MessageIDs: make([]string, 0),
267
+ StartTime: time.Time{},
268
+ EndTime: time.Time{},
269
+ Tokens: 0,
270
+ Summary: "",
271
+ SummaryTokens: 0,
272
+ Status: EpisodeActive,
273
+ Tags: make([]string, 0),
274
+ ParentEpisodeID: parentEpisodeID,
275
+ }
276
+ m.episodes[ep.ID] = ep
277
+ m.episodeSeq = append(m.episodeSeq, ep)
278
+ return ep
279
+ }
280
+
281
+ func buildEpisodeTitle(content string) string {
282
+ trimmed := strings.TrimSpace(content)
283
+ if trimmed == "" {
284
+ return "Untitled Episode"
285
+ }
286
+ parts := strings.Fields(trimmed)
287
+ if len(parts) > 8 {
288
+ parts = parts[:8]
289
+ }
290
+ return strings.Join(parts, " ")
291
+ }
292
+
293
+ func buildEpisodeTags(content string) []string {
294
+ trimmed := strings.ToLower(strings.TrimSpace(content))
295
+ if trimmed == "" {
296
+ return nil
297
+ }
298
+ parts := strings.Fields(trimmed)
299
+ if len(parts) > 3 {
300
+ parts = parts[:3]
301
+ }
302
+ out := make([]string, 0, len(parts))
303
+ seen := make(map[string]bool)
304
+ for _, p := range parts {
305
+ p = strings.Trim(p, ",.;:!?()[]{}\"'")
306
+ if p == "" || seen[p] {
307
+ continue
308
+ }
309
+ seen[p] = true
310
+ out = append(out, p)
311
+ }
312
+ return out
313
+ }
@@ -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
+ }