/ internal / session / scenarios_test.go
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  }