manager_test.go
1 package session 2 3 import ( 4 "testing" 5 "time" 6 7 "github.com/Kocoro-lab/ShanClaw/internal/client" 8 ) 9 10 func TestManager_ResumeLatest_EmptyDir(t *testing.T) { 11 dir := t.TempDir() 12 m := NewManager(dir) 13 defer m.Close() 14 15 sess, err := m.ResumeLatest() 16 if err != nil { 17 t.Fatalf("unexpected error on empty dir: %v", err) 18 } 19 if sess != nil { 20 t.Error("expected nil session for empty directory") 21 } 22 } 23 24 func TestManager_ResumeLatest_FindsMostRecentByUpdatedAt(t *testing.T) { 25 dir := t.TempDir() 26 store := NewStore(dir) 27 defer store.Close() 28 29 // Create "older-created" session first, then update it later 30 // Create "newer-created" session second, but don't update it 31 // ResumeLatest should pick "older-created" because it was updated more recently. 32 33 olderCreated := &Session{ 34 ID: "older-created", 35 Title: "Created first", 36 CreatedAt: time.Now().Add(-2 * time.Hour), 37 Messages: []client.Message{ 38 {Role: "user", Content: client.NewTextContent("first message")}, 39 }, 40 } 41 store.Save(olderCreated) // UpdatedAt = now 42 43 // Simulate passage of time 44 time.Sleep(10 * time.Millisecond) 45 46 newerCreated := &Session{ 47 ID: "newer-created", 48 Title: "Created second", 49 CreatedAt: time.Now(), 50 Messages: []client.Message{ 51 {Role: "user", Content: client.NewTextContent("second message")}, 52 }, 53 } 54 store.Save(newerCreated) // UpdatedAt = now (slightly later) 55 56 // Now update the older-created session (simulating daemon appending a turn) 57 time.Sleep(10 * time.Millisecond) 58 olderCreated.Messages = append(olderCreated.Messages, 59 client.Message{Role: "assistant", Content: client.NewTextContent("reply")}, 60 ) 61 store.Save(olderCreated) // UpdatedAt = now (latest) 62 63 m := NewManager(dir) 64 defer m.Close() 65 sess, err := m.ResumeLatest() 66 if err != nil { 67 t.Fatalf("unexpected error: %v", err) 68 } 69 if sess == nil { 70 t.Fatal("expected a session, got nil") 71 } 72 // Should pick "older-created" because it has the latest UpdatedAt 73 if sess.ID != "older-created" { 74 t.Errorf("expected 'older-created' (most recently updated), got %q", sess.ID) 75 } 76 if len(sess.Messages) != 2 { 77 t.Errorf("expected 2 messages, got %d", len(sess.Messages)) 78 } 79 if m.Current() == nil || m.Current().ID != "older-created" { 80 t.Error("ResumeLatest should set the session as current") 81 } 82 } 83 84 func TestManager_ResumeLatest_SingleSession(t *testing.T) { 85 dir := t.TempDir() 86 store := NewStore(dir) 87 defer store.Close() 88 89 store.Save(&Session{ 90 ID: "only-one", 91 Title: "Only session", 92 Messages: []client.Message{ 93 {Role: "user", Content: client.NewTextContent("hello")}, 94 }, 95 }) 96 97 m := NewManager(dir) 98 defer m.Close() 99 sess, err := m.ResumeLatest() 100 if err != nil { 101 t.Fatalf("unexpected error: %v", err) 102 } 103 if sess.ID != "only-one" { 104 t.Errorf("expected 'only-one', got %q", sess.ID) 105 } 106 } 107 108 func TestManager_OnSessionClose_FiresOnSessionSwitch(t *testing.T) { 109 dir := t.TempDir() 110 m := NewManager(dir) 111 defer m.Close() 112 113 s1 := m.NewSession() 114 calls := 0 115 m.OnSessionClose(s1.ID, func() { calls++ }) 116 117 s2 := m.NewSession() 118 if s2 == nil { 119 t.Fatal("expected second session") 120 } 121 if calls != 1 { 122 t.Fatalf("expected callback to fire once when switching sessions, got %d", calls) 123 } 124 } 125 126 func TestManager_OnSessionClose_AppendsCallbacks(t *testing.T) { 127 dir := t.TempDir() 128 m := NewManager(dir) 129 130 sess := m.NewSession() 131 total := 0 132 // Multiple subsystems (spill cleanup, file-preview teardown, etc.) 133 // each register their own close hook. All must fire — replace 134 // semantics would silently leak resources. 135 m.OnSessionClose(sess.ID, func() { total += 1 }) 136 m.OnSessionClose(sess.ID, func() { total += 10 }) 137 138 if err := m.Close(); err != nil { 139 t.Fatalf("close failed: %v", err) 140 } 141 if total != 11 { 142 t.Fatalf("expected both callbacks to fire (append semantics), got total %d", total) 143 } 144 } 145 146 func TestManager_OnSessionClose_AppendFiresOnSessionSwitch(t *testing.T) { 147 dir := t.TempDir() 148 m := NewManager(dir) 149 defer m.Close() 150 151 sess := m.NewSession() 152 total := 0 153 m.OnSessionClose(sess.ID, func() { total += 1 }) 154 m.OnSessionClose(sess.ID, func() { total += 100 }) 155 156 // Switching sessions fires the close hooks for the previous session. 157 _ = m.NewSession() 158 if total != 101 { 159 t.Fatalf("append-on-close after switch: want 101, got %d", total) 160 } 161 } 162 163 func TestManager_WorkingSet_IsScopedPerSession(t *testing.T) { 164 dir := t.TempDir() 165 m := NewManager(dir) 166 defer m.Close() 167 168 s1 := m.NewSession() 169 if err := m.Save(); err != nil { 170 t.Fatalf("save first session: %v", err) 171 } 172 ws1 := m.WorkingSet(s1.ID) 173 if ws1 == nil { 174 t.Fatal("expected working set for first session") 175 } 176 ws1.Add("browser_click", client.Tool{Type: "function", Function: client.FunctionDef{Name: "browser_click"}}) 177 178 s2 := m.NewSession() 179 if err := m.Save(); err != nil { 180 t.Fatalf("save second session: %v", err) 181 } 182 ws2 := m.WorkingSet(s2.ID) 183 if ws2 == nil { 184 t.Fatal("expected working set for second session") 185 } 186 if ws2.Contains("browser_click") { 187 t.Fatal("second session should not inherit first session's warmed tools") 188 } 189 190 if _, err := m.Resume(s1.ID); err != nil { 191 t.Fatalf("resume first session: %v", err) 192 } 193 ws1Again := m.CurrentWorkingSet() 194 if ws1Again == nil { 195 t.Fatal("expected working set after resuming first session") 196 } 197 if !ws1Again.Contains("browser_click") { 198 t.Fatal("resumed first session should retain its working set") 199 } 200 } 201 202 func TestManager_Reset_ClearsHistoryInPlace(t *testing.T) { 203 dir := t.TempDir() 204 m := NewManager(dir) 205 defer m.Close() 206 207 // Seed a session with messages, meta, summary cache, and usage. 208 sess := m.NewSession() 209 origID := sess.ID 210 sess.Title = "Kept title" 211 sess.CWD = "/keep/here" 212 sess.Source = "slack" 213 sess.Channel = "C123" 214 sess.Messages = []client.Message{ 215 {Role: "user", Content: client.NewTextContent("hello")}, 216 {Role: "assistant", Content: client.NewTextContent("hi")}, 217 } 218 sess.MessageMeta = []MessageMeta{{Source: "local"}, {Source: "local"}} 219 sess.RemoteTasks = []string{"task-1"} 220 sess.SummaryCache = "cached summary" 221 sess.SummaryCacheKey = "key-1" 222 sess.InProgress = true 223 if err := m.Save(); err != nil { 224 t.Fatalf("seed save failed: %v", err) 225 } 226 m.AddUsage(origID, UsageSummary{InputTokens: 100, CostUSD: 0.5}) 227 if err := m.Save(); err != nil { 228 t.Fatalf("seed usage save failed: %v", err) 229 } 230 231 if err := m.Reset(origID); err != nil { 232 t.Fatalf("Reset failed: %v", err) 233 } 234 235 cur := m.Current() 236 if cur == nil || cur.ID != origID { 237 t.Fatalf("current session should still be %q, got %+v", origID, cur) 238 } 239 if cur.Title != "Kept title" { 240 t.Errorf("Title should be preserved, got %q", cur.Title) 241 } 242 if cur.CWD != "/keep/here" { 243 t.Errorf("CWD should be preserved, got %q", cur.CWD) 244 } 245 if cur.Source != "slack" || cur.Channel != "C123" { 246 t.Errorf("Source/Channel should be preserved, got %q/%q", cur.Source, cur.Channel) 247 } 248 if cur.Usage == nil || cur.Usage.InputTokens != 100 { 249 t.Errorf("Usage should be preserved, got %+v", cur.Usage) 250 } 251 if len(cur.Messages) != 0 { 252 t.Errorf("Messages should be cleared, got %d", len(cur.Messages)) 253 } 254 if len(cur.MessageMeta) != 0 { 255 t.Errorf("MessageMeta should be cleared, got %d", len(cur.MessageMeta)) 256 } 257 if len(cur.RemoteTasks) != 0 { 258 t.Errorf("RemoteTasks should be cleared, got %d", len(cur.RemoteTasks)) 259 } 260 if cur.SummaryCache != "" || cur.SummaryCacheKey != "" { 261 t.Errorf("Summary cache should be cleared, got %q/%q", cur.SummaryCache, cur.SummaryCacheKey) 262 } 263 if cur.InProgress { 264 t.Error("InProgress should be cleared") 265 } 266 267 // Reload from disk to confirm persistence. 268 m2 := NewManager(dir) 269 defer m2.Close() 270 loaded, err := m2.Load(origID) 271 if err != nil { 272 t.Fatalf("reload failed: %v", err) 273 } 274 if len(loaded.Messages) != 0 { 275 t.Errorf("persisted messages should be cleared, got %d", len(loaded.Messages)) 276 } 277 if loaded.Title != "Kept title" { 278 t.Errorf("persisted title should be preserved, got %q", loaded.Title) 279 } 280 if loaded.Usage == nil || loaded.Usage.InputTokens != 100 { 281 t.Errorf("persisted usage should be preserved, got %+v", loaded.Usage) 282 } 283 } 284 285 func TestManager_Reset_NotFound(t *testing.T) { 286 dir := t.TempDir() 287 m := NewManager(dir) 288 defer m.Close() 289 290 err := m.Reset("does-not-exist") 291 if err == nil { 292 t.Fatal("expected error for missing session") 293 } 294 } 295 296 func TestManager_Reset_EmptyID(t *testing.T) { 297 dir := t.TempDir() 298 m := NewManager(dir) 299 defer m.Close() 300 301 if err := m.Reset(""); err == nil { 302 t.Fatal("expected error for empty id") 303 } 304 } 305 306 func TestManager_Reset_ResetsWorkingSet(t *testing.T) { 307 dir := t.TempDir() 308 m := NewManager(dir) 309 defer m.Close() 310 311 sess := m.NewSession() 312 if err := m.Save(); err != nil { 313 t.Fatalf("save failed: %v", err) 314 } 315 ws := m.WorkingSet(sess.ID) 316 ws.Add("browser_click", client.Tool{Name: "browser_click"}) 317 if !ws.Contains("browser_click") { 318 t.Fatal("seed: working set should contain browser_click") 319 } 320 321 if err := m.Reset(sess.ID); err != nil { 322 t.Fatalf("Reset failed: %v", err) 323 } 324 325 wsAfter := m.WorkingSet(sess.ID) 326 if wsAfter == nil { 327 t.Fatal("working set should exist after reset") 328 } 329 if wsAfter.Contains("browser_click") { 330 t.Error("working set should be cleared after reset") 331 } 332 }