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