scenarios_test.go
1 package session 2 3 import ( 4 "os" 5 "path/filepath" 6 "strings" 7 "testing" 8 "time" 9 10 "github.com/Kocoro-lab/ShanClaw/internal/client" 11 ) 12 13 func countJSON(entries []os.DirEntry) int { 14 n := 0 15 for _, e := range entries { 16 if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") { 17 n++ 18 } 19 } 20 return n 21 } 22 23 // These tests validate every session storage scenario: 24 // 25 // | Mode | Session per | Resume | Persist | Storage | 26 // |----------------|-------------|---------------|------------------|--------------------------------------| 27 // | Daemon | agent | auto (latest) | every turn | ~/.shannon/agents/<name>/sessions/ | 28 // | Daemon default | default | auto (latest) | every turn | ~/.shannon/sessions/ | 29 // | TUI | invocation | manual | on quit+per turn | same dirs | 30 // | One-shot | invocation | never | after completion | same dirs | 31 // | Schedule | invocation | never | after completion | same dirs (goes through runOneShot) | 32 33 func TestScenario_DaemonNamedAgent_PersistsAndResumes(t *testing.T) { 34 shanDir := t.TempDir() 35 sessDir := filepath.Join(shanDir, "agents", "ops-bot", "sessions") 36 os.MkdirAll(sessDir, 0700) 37 38 // Turn 1: daemon creates a new session for ops-bot 39 mgr := NewManager(sessDir) 40 defer mgr.Close() 41 sess, _ := mgr.ResumeLatest() // nil — no sessions yet 42 if sess != nil { 43 t.Fatal("expected no session on first run") 44 } 45 sess = mgr.NewSession() 46 sess.Title = "ops-bot daemon session" 47 sess.Messages = append(sess.Messages, 48 client.Message{Role: "user", Content: client.NewTextContent("check prod")}, 49 client.Message{Role: "assistant", Content: client.NewTextContent("prod is healthy")}, 50 ) 51 if err := mgr.Save(); err != nil { 52 t.Fatalf("save failed: %v", err) 53 } 54 sessionID := sess.ID 55 56 // Turn 2: simulate daemon receiving another message (same agent) 57 // In real daemon, GetOrCreate returns cached manager, but here we simulate restart 58 mgr2 := NewManager(sessDir) 59 defer mgr2.Close() 60 resumed, err := mgr2.ResumeLatest() 61 if err != nil { 62 t.Fatalf("resume failed: %v", err) 63 } 64 if resumed == nil { 65 t.Fatal("expected to resume session") 66 } 67 if resumed.ID != sessionID { 68 t.Errorf("expected same session ID %q, got %q", sessionID, resumed.ID) 69 } 70 if len(resumed.Messages) != 2 { 71 t.Fatalf("expected 2 messages from turn 1, got %d", len(resumed.Messages)) 72 } 73 74 // Append turn 2 75 resumed.Messages = append(resumed.Messages, 76 client.Message{Role: "user", Content: client.NewTextContent("deploy staging")}, 77 client.Message{Role: "assistant", Content: client.NewTextContent("deployed")}, 78 ) 79 if err := mgr2.Save(); err != nil { 80 t.Fatalf("save turn 2 failed: %v", err) 81 } 82 83 // Turn 3: simulate another restart — should see all 4 messages 84 mgr3 := NewManager(sessDir) 85 defer mgr3.Close() 86 final, err := mgr3.ResumeLatest() 87 if err != nil { 88 t.Fatalf("resume failed: %v", err) 89 } 90 if len(final.Messages) != 4 { 91 t.Errorf("expected 4 messages across turns, got %d", len(final.Messages)) 92 } 93 if final.Messages[2].Content.Text() != "deploy staging" { 94 t.Errorf("turn 2 message not persisted: got %q", final.Messages[2].Content.Text()) 95 } 96 } 97 98 func TestScenario_DaemonDefaultAgent_UsesGlobalSessionsDir(t *testing.T) { 99 shanDir := t.TempDir() 100 sessDir := filepath.Join(shanDir, "sessions") 101 os.MkdirAll(sessDir, 0700) 102 103 mgr := NewManager(sessDir) 104 defer mgr.Close() 105 sess := mgr.NewSession() 106 sess.Messages = append(sess.Messages, 107 client.Message{Role: "user", Content: client.NewTextContent("hello")}, 108 client.Message{Role: "assistant", Content: client.NewTextContent("hi")}, 109 ) 110 mgr.Save() 111 112 // Verify file exists in the right directory 113 files, _ := os.ReadDir(sessDir) 114 jsonCount := countJSON(files) 115 if jsonCount != 1 { 116 t.Errorf("expected 1 session file in %s, got %d", sessDir, jsonCount) 117 } 118 119 // Resume should find it 120 mgr2 := NewManager(sessDir) 121 defer mgr2.Close() 122 resumed, _ := mgr2.ResumeLatest() 123 if resumed == nil { 124 t.Fatal("expected to resume default session") 125 } 126 if len(resumed.Messages) != 2 { 127 t.Errorf("expected 2 messages, got %d", len(resumed.Messages)) 128 } 129 } 130 131 func TestScenario_OneShotCreatesNewSession(t *testing.T) { 132 shanDir := t.TempDir() 133 sessDir := filepath.Join(shanDir, "agents", "reviewer", "sessions") 134 os.MkdirAll(sessDir, 0700) 135 136 // Simulate two one-shot invocations 137 for i, query := range []string{"review PR #123", "review PR #456"} { 138 mgr := NewManager(sessDir) 139 defer mgr.Close() 140 sess := mgr.NewSession() 141 sess.Title = query 142 sess.Messages = append(sess.Messages, 143 client.Message{Role: "user", Content: client.NewTextContent(query)}, 144 client.Message{Role: "assistant", Content: client.NewTextContent("reviewed")}, 145 ) 146 if err := mgr.Save(); err != nil { 147 t.Fatalf("one-shot %d save failed: %v", i, err) 148 } 149 // Small delay to ensure different timestamps 150 time.Sleep(10 * time.Millisecond) 151 } 152 153 // Should have 2 separate session files 154 files, _ := os.ReadDir(sessDir) 155 jsonCount := countJSON(files) 156 if jsonCount != 2 { 157 t.Errorf("expected 2 session files for 2 one-shot runs, got %d", jsonCount) 158 } 159 160 // ResumeLatest picks the most recent one (PR #456) 161 mgr := NewManager(sessDir) 162 defer mgr.Close() 163 latest, _ := mgr.ResumeLatest() 164 if latest == nil { 165 t.Fatal("expected to find a session") 166 } 167 if latest.Title != "review PR #456" { 168 t.Errorf("expected latest to be 'review PR #456', got %q", latest.Title) 169 } 170 } 171 172 func TestScenario_TUICreatesNewAndResumeByID(t *testing.T) { 173 sessDir := t.TempDir() 174 175 // TUI session 1 176 mgr := NewManager(sessDir) 177 defer mgr.Close() 178 s1 := mgr.NewSession() 179 s1.Title = "TUI session 1" 180 s1.Messages = append(s1.Messages, 181 client.Message{Role: "user", Content: client.NewTextContent("first")}, 182 ) 183 mgr.Save() 184 id1 := s1.ID 185 186 // TUI session 2 (new invocation) 187 time.Sleep(10 * time.Millisecond) 188 mgr2 := NewManager(sessDir) 189 defer mgr2.Close() 190 s2 := mgr2.NewSession() 191 s2.Title = "TUI session 2" 192 s2.Messages = append(s2.Messages, 193 client.Message{Role: "user", Content: client.NewTextContent("second")}, 194 ) 195 mgr2.Save() 196 197 // User /resume on session 1 by ID 198 mgr3 := NewManager(sessDir) 199 defer mgr3.Close() 200 resumed, err := mgr3.Resume(id1) 201 if err != nil { 202 t.Fatalf("resume by ID failed: %v", err) 203 } 204 if resumed.Title != "TUI session 1" { 205 t.Errorf("expected 'TUI session 1', got %q", resumed.Title) 206 } 207 } 208 209 func TestScenario_DaemonAndOneShotSameAgent_NoConflict(t *testing.T) { 210 shanDir := t.TempDir() 211 sessDir := filepath.Join(shanDir, "agents", "ops-bot", "sessions") 212 os.MkdirAll(sessDir, 0700) 213 214 // Daemon creates and uses a session 215 daemonMgr := NewManager(sessDir) 216 defer daemonMgr.Close() 217 daemonSess := daemonMgr.NewSession() 218 daemonSess.Title = "daemon session" 219 daemonSess.Messages = append(daemonSess.Messages, 220 client.Message{Role: "user", Content: client.NewTextContent("daemon msg 1")}, 221 client.Message{Role: "assistant", Content: client.NewTextContent("daemon reply 1")}, 222 ) 223 daemonMgr.Save() 224 daemonID := daemonSess.ID 225 226 time.Sleep(10 * time.Millisecond) 227 228 // One-shot creates a separate session 229 oneshotMgr := NewManager(sessDir) 230 defer oneshotMgr.Close() 231 oneshotSess := oneshotMgr.NewSession() 232 oneshotSess.Title = "oneshot task" 233 oneshotSess.Messages = append(oneshotSess.Messages, 234 client.Message{Role: "user", Content: client.NewTextContent("quick task")}, 235 client.Message{Role: "assistant", Content: client.NewTextContent("done")}, 236 ) 237 oneshotMgr.Save() 238 239 // Two session files 240 files, _ := os.ReadDir(sessDir) 241 jsonCount := countJSON(files) 242 if jsonCount != 2 { 243 t.Errorf("expected 2 session files, got %d", jsonCount) 244 } 245 246 time.Sleep(10 * time.Millisecond) 247 248 // Daemon appends another turn to its session 249 daemonMgr2 := NewManager(sessDir) 250 defer daemonMgr2.Close() 251 resumed, _ := daemonMgr2.Resume(daemonID) 252 if resumed == nil { 253 t.Fatal("daemon should resume its own session by ID") 254 } 255 resumed.Messages = append(resumed.Messages, 256 client.Message{Role: "user", Content: client.NewTextContent("daemon msg 2")}, 257 client.Message{Role: "assistant", Content: client.NewTextContent("daemon reply 2")}, 258 ) 259 daemonMgr2.Save() 260 261 // ResumeLatest should pick daemon session (most recently updated) 262 latestMgr := NewManager(sessDir) 263 defer latestMgr.Close() 264 latest, _ := latestMgr.ResumeLatest() 265 if latest == nil { 266 t.Fatal("expected to find latest session") 267 } 268 if latest.ID != daemonID { 269 t.Errorf("expected daemon session (most recently updated), got %q", latest.ID) 270 } 271 if len(latest.Messages) != 4 { 272 t.Errorf("expected 4 messages in daemon session, got %d", len(latest.Messages)) 273 } 274 } 275 276 func TestScenario_DaemonResumeLatest_PicksUpdatedNotCreated(t *testing.T) { 277 sessDir := t.TempDir() 278 279 // Session A: created first 280 mgrA := NewManager(sessDir) 281 defer mgrA.Close() 282 sA := mgrA.NewSession() 283 sA.Title = "Session A (older)" 284 sA.Messages = append(sA.Messages, 285 client.Message{Role: "user", Content: client.NewTextContent("A")}, 286 ) 287 mgrA.Save() 288 289 time.Sleep(20 * time.Millisecond) 290 291 // Session B: created second (newer CreatedAt) 292 mgrB := NewManager(sessDir) 293 defer mgrB.Close() 294 sB := mgrB.NewSession() 295 sB.Title = "Session B (newer created)" 296 sB.Messages = append(sB.Messages, 297 client.Message{Role: "user", Content: client.NewTextContent("B")}, 298 ) 299 mgrB.Save() 300 301 time.Sleep(20 * time.Millisecond) 302 303 // Update session A (now has latest UpdatedAt despite older CreatedAt) 304 mgrA2 := NewManager(sessDir) 305 defer mgrA2.Close() 306 resumedA, _ := mgrA2.Resume(sA.ID) 307 resumedA.Messages = append(resumedA.Messages, 308 client.Message{Role: "assistant", Content: client.NewTextContent("reply to A")}, 309 ) 310 mgrA2.Save() 311 312 // ResumeLatest should pick A (most recent UpdatedAt), not B (most recent CreatedAt) 313 mgrFinal := NewManager(sessDir) 314 defer mgrFinal.Close() 315 latest, _ := mgrFinal.ResumeLatest() 316 if latest == nil { 317 t.Fatal("expected to find session") 318 } 319 if latest.ID != sA.ID { 320 t.Errorf("expected session A (most recently updated), got %q with title %q", latest.ID, latest.Title) 321 } 322 }