返回 DeepSeek-Reasonix
session_lease_test.go
根目录 / internal / cli / session_lease_test.go
1 package cli
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/control"
13 "reasonix/internal/event"
14 "reasonix/internal/provider"
15 "reasonix/internal/store"
16 )
17
18 // holdSessionLease simulates another runtime owning path for the duration of
19 // the test (the in-process lease registry plus the OS lock behave exactly as a
20 // foreign holder for acquisition purposes).
21 func holdSessionLease(t *testing.T, path string) *agent.SessionLease {
22 t.Helper()
23 lease, err := agent.TryAcquireSessionLease(path)
24 if err != nil {
25 t.Fatalf("test holder acquire: %v", err)
26 }
27 t.Cleanup(lease.Release)
28 return lease
29 }
30
31 func TestRunResumeRefusedWhenSessionLeaseHeld(t *testing.T) {
32 isolateCLIConfigHome(t)
33
34 path := filepath.Join(t.TempDir(), "held-run.jsonl")
35 saveTestSession(t, path, "held prompt")
36 holdSessionLease(t, path)
37
38 errOut := captureStderr(t, func() {
39 if rc := runAgent([]string{"--resume", path, "continue task"}, "dev"); rc != 1 {
40 t.Fatalf("run --resume held rc = %d, want 1", rc)
41 }
42 })
43 if !strings.Contains(errOut, "in use by another Reasonix") {
44 t.Fatalf("run --resume held stderr = %q, want holder wording", errOut)
45 }
46 if !strings.Contains(errOut, "--copy") {
47 t.Fatalf("run --resume held stderr = %q, want --copy guidance", errOut)
48 }
49 if strings.Contains(errOut, path) {
50 t.Fatalf("run --resume held stderr leaks the session path: %q", errOut)
51 }
52 }
53
54 func TestRunCopyRequiresResumeTarget(t *testing.T) {
55 isolateCLIConfigHome(t)
56
57 errOut := captureStderr(t, func() {
58 if rc := runAgent([]string{"--copy", "do things"}, "dev"); rc != 2 {
59 t.Fatalf("run --copy without target rc = %d, want 2", rc)
60 }
61 })
62 if !strings.Contains(errOut, "--copy requires --resume or --continue") {
63 t.Fatalf("run --copy stderr = %q, want usage error", errOut)
64 }
65 }
66
67 // TestRunResumeCopyJSONKeepsStdoutClean guards that --copy under a structured
68 // output format writes its human notice to stderr, leaving stdout a single valid
69 // JSON object (the copy notice used to pollute it).
70 func TestRunResumeCopyJSONKeepsStdoutClean(t *testing.T) {
71 isolateCLIConfigHome(t)
72
73 dir := t.TempDir()
74 src := filepath.Join(dir, "held-src.jsonl")
75 saveTestSession(t, src, "copy me")
76 holdSessionLease(t, src)
77
78 var rc int
79 var errOut string
80 out := captureStdout(t, func() {
81 errOut = captureStderr(t, func() {
82 rc = runAgent([]string{"--resume", src, "--copy", "--output-format", "json", "continue task"}, "dev")
83 })
84 })
85 // Setup fails in the isolated home (no provider), so the run ends with a JSON
86 // error object — but the copy still happened first.
87 if rc != 1 {
88 t.Fatalf("rc = %d, want 1 (setup fails in isolated home)", rc)
89 }
90 if strings.Contains(out, "continuing in a session copy") {
91 t.Fatalf("json stdout leaked the copy notice:\n%s", out)
92 }
93 if !strings.Contains(errOut, "continuing in a session copy: ") {
94 t.Fatalf("copy notice should be on stderr, got stderr:\n%s", errOut)
95 }
96 var obj map[string]any
97 if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &obj); err != nil {
98 t.Fatalf("stdout is not a single JSON object: %v\nstdout:\n%s", err, out)
99 }
100 }
101
102 func TestRunResumeCopyContinuesInDuplicate(t *testing.T) {
103 isolateCLIConfigHome(t)
104
105 dir := t.TempDir()
106 src := filepath.Join(dir, "held-src.jsonl")
107 saveTestSession(t, src, "copy me")
108 srcBytes, err := os.ReadFile(src)
109 if err != nil {
110 t.Fatal(err)
111 }
112 holdSessionLease(t, src)
113
114 var rc int
115 out := captureStdout(t, func() {
116 _ = captureStderr(t, func() {
117 rc = runAgent([]string{"--resume", src, "--copy", "continue task"}, "dev")
118 })
119 })
120 // The isolated home has no provider config, so the run itself fails after
121 // the copy — but never with the lease refusal, and never touching src.
122 if rc != 1 {
123 t.Fatalf("run --resume --copy rc = %d, want 1 (setup fails in isolated home)", rc)
124 }
125 if !strings.Contains(out, "continuing in a session copy: ") {
126 t.Fatalf("stdout = %q, want session copy line", out)
127 }
128 copyPath := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(out), "continuing in a session copy:"))
129 if copyPath == "" || copyPath == src {
130 t.Fatalf("copy path = %q, want a fresh path", copyPath)
131 }
132
133 srcAfter, err := os.ReadFile(src)
134 if err != nil {
135 t.Fatal(err)
136 }
137 if string(srcAfter) != string(srcBytes) {
138 t.Fatalf("source transcript was modified by --copy")
139 }
140 srcLoaded, err := agent.LoadSession(src)
141 if err != nil {
142 t.Fatal(err)
143 }
144 copyLoaded, err := agent.LoadSession(copyPath)
145 if err != nil {
146 t.Fatalf("load copy: %v", err)
147 }
148 srcMsgs, copyMsgs := srcLoaded.Snapshot(), copyLoaded.Snapshot()
149 if len(copyMsgs) != len(srcMsgs) {
150 t.Fatalf("copy has %d messages, source %d", len(copyMsgs), len(srcMsgs))
151 }
152 for i := range srcMsgs {
153 if copyMsgs[i].Role != srcMsgs[i].Role || copyMsgs[i].Content != srcMsgs[i].Content {
154 t.Fatalf("copy message %d = %+v, want %+v", i, copyMsgs[i], srcMsgs[i])
155 }
156 }
157 // The run exited: the copy's lease must be released again.
158 if _, err := os.Stat(store.SessionLeaseInfo(agent.CanonicalSessionPath(copyPath))); !os.IsNotExist(err) {
159 t.Fatalf("copy lease info after exit stat err = %v, want not exist", err)
160 }
161 lease, err := agent.TryAcquireSessionLease(copyPath)
162 if err != nil {
163 t.Fatalf("copy lease not released after run: %v", err)
164 }
165 lease.Release()
166 }
167
168 func TestRunResumeReleasesLeaseOnExit(t *testing.T) {
169 isolateCLIConfigHome(t)
170
171 path := filepath.Join(t.TempDir(), "release-run.jsonl")
172 saveTestSession(t, path, "resume me")
173
174 // No provider config in the isolated home: the run fails after the lease
175 // was taken, and the deferred release must still run.
176 _ = captureStderr(t, func() {
177 if rc := runAgent([]string{"--resume", path, "continue task"}, "dev"); rc != 1 {
178 t.Fatalf("run --resume rc = %d, want 1 (setup fails in isolated home)", rc)
179 }
180 })
181 if _, err := os.Stat(store.SessionLeaseInfo(agent.CanonicalSessionPath(path))); !os.IsNotExist(err) {
182 t.Fatalf("lease info after run exit stat err = %v, want not exist", err)
183 }
184 lease, err := agent.TryAcquireSessionLease(path)
185 if err != nil {
186 t.Fatalf("lease not released after run exit: %v", err)
187 }
188 lease.Release()
189 }
190
191 func TestCopySessionForWritingDuplicatesTranscript(t *testing.T) {
192 dir := t.TempDir()
193 src := filepath.Join(dir, "src.jsonl")
194 s := agent.NewSession("sys")
195 s.Add(provider.Message{Role: provider.RoleUser, Content: "question"})
196 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
197 if err := s.Save(src); err != nil {
198 t.Fatal(err)
199 }
200 if err := agent.SaveBranchMeta(src, agent.BranchMeta{
201 CustomTitle: "My debugging session",
202 Model: "deepseek/deepseek-chat",
203 SchemaVersion: agent.BranchMetaCountsVersion,
204 }); err != nil {
205 t.Fatal(err)
206 }
207 srcBytes, err := os.ReadFile(src)
208 if err != nil {
209 t.Fatal(err)
210 }
211
212 copyPath, err := copySessionForWriting(src)
213 if err != nil {
214 t.Fatalf("copySessionForWriting: %v", err)
215 }
216 if filepath.Dir(copyPath) != dir {
217 t.Fatalf("copy landed in %q, want beside the source in %q", filepath.Dir(copyPath), dir)
218 }
219
220 loaded, err := agent.LoadSession(copyPath)
221 if err != nil {
222 t.Fatalf("load copy: %v", err)
223 }
224 got := loaded.Snapshot()
225 want := s.Snapshot()
226 if len(got) != len(want) {
227 t.Fatalf("copy has %d messages, want %d", len(got), len(want))
228 }
229 for i := range want {
230 if got[i].Role != want[i].Role || got[i].Content != want[i].Content {
231 t.Fatalf("copy message %d = %+v, want %+v", i, got[i], want[i])
232 }
233 }
234
235 meta, ok, err := agent.LoadBranchMeta(copyPath)
236 if err != nil || !ok {
237 t.Fatalf("copy branch meta: ok=%v err=%v", ok, err)
238 }
239 if meta.ParentID != agent.BranchID(src) {
240 t.Fatalf("copy ParentID = %q, want %q", meta.ParentID, agent.BranchID(src))
241 }
242 if meta.CustomTitle != "My debugging session (copy)" {
243 t.Fatalf("copy CustomTitle = %q", meta.CustomTitle)
244 }
245 if meta.Model != "deepseek/deepseek-chat" {
246 t.Fatalf("copy Model = %q", meta.Model)
247 }
248
249 // The copy starts unowned and without lease/lock sidecars of its own.
250 for _, sidecar := range []string{
251 store.SessionLeaseInfo(agent.CanonicalSessionPath(copyPath)),
252 store.SessionLeaseLock(agent.CanonicalSessionPath(copyPath)),
253 } {
254 if _, err := os.Stat(sidecar); !os.IsNotExist(err) {
255 t.Fatalf("copy has lease sidecar %s (err=%v)", sidecar, err)
256 }
257 }
258 // The source transcript is only read.
259 srcAfter, err := os.ReadFile(src)
260 if err != nil {
261 t.Fatal(err)
262 }
263 if string(srcAfter) != string(srcBytes) {
264 t.Fatalf("source transcript was modified by the copy")
265 }
266 }
267
268 // chatLeaseFixture builds a TUI over a temp session dir with two saved
269 // sessions: older (the active one) and newer (the /resume 1 target).
270 func chatLeaseFixture(t *testing.T) (m chatTUI, active, target string) {
271 t.Helper()
272 dir := t.TempDir()
273 active = filepath.Join(dir, "a-active.jsonl")
274 target = filepath.Join(dir, "b-target.jsonl")
275 saveTestSession(t, active, "active session")
276 saveTestSession(t, target, "target session")
277 pinNewer(t, active, target)
278
279 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
280 m = newTestChatTUI()
281 m.width = 80
282 m.ctrl = control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: active, Label: "test"})
283 m.leases = control.NewSessionLeaseKeeper()
284 t.Cleanup(m.leases.Release)
285 if err := m.leases.Rebind(active); err != nil {
286 t.Fatalf("seed lease on active: %v", err)
287 }
288 return m, active, target
289 }
290
291 // pinNewer gives newer a strictly later mtime than older so ListSessions
292 // ordering is deterministic across filesystems with coarse mtimes.
293 func pinNewer(t *testing.T, older, newer string) {
294 t.Helper()
295 info, err := os.Stat(newer)
296 if err != nil {
297 t.Fatal(err)
298 }
299 old := info.ModTime().Add(-2 * time.Second)
300 if err := os.Chtimes(older, old, old); err != nil {
301 t.Fatal(err)
302 }
303 }
304
305 func TestChatResumeCommandRefusedWhenLeaseHeld(t *testing.T) {
306 m, active, target := chatLeaseFixture(t)
307 holdSessionLease(t, target)
308
309 m.runResumeCommand("/resume 1")
310
311 out := strings.Join(m.transcript, "\n")
312 if !strings.Contains(out, "in use by another Reasonix") {
313 t.Fatalf("refusal notice missing from transcript:\n%s", out)
314 }
315 if got := m.ctrl.SessionPath(); got != active {
316 t.Fatalf("session path after refused /resume = %q, want %q", got, active)
317 }
318 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(active); got != want {
319 t.Fatalf("lease after refused /resume = %q, want %q", got, want)
320 }
321 }
322
323 func TestChatResumeCommandMovesLease(t *testing.T) {
324 m, active, target := chatLeaseFixture(t)
325
326 m.runResumeCommand("/resume 1")
327
328 if got := m.ctrl.SessionPath(); got != target {
329 t.Fatalf("session path after /resume = %q, want %q", got, target)
330 }
331 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(target); got != want {
332 t.Fatalf("lease after /resume = %q, want %q", got, want)
333 }
334 // The lease on the session we left must be free again.
335 lease, err := agent.TryAcquireSessionLease(active)
336 if err != nil {
337 t.Fatalf("old session lease not released by /resume: %v", err)
338 }
339 lease.Release()
340 }
341
342 func TestChatNewSessionTakesFreshLease(t *testing.T) {
343 m, active, _ := chatLeaseFixture(t)
344
345 if cmd := m.runSlashCommand("/new"); cmd != nil {
346 t.Fatal("/new should not return a tea.Cmd")
347 }
348
349 fresh := m.ctrl.SessionPath()
350 if fresh == active {
351 t.Fatalf("/new did not rotate the session path")
352 }
353 if got, want := m.leases.HeldPath(), agent.CanonicalSessionPath(fresh); got != want {
354 t.Fatalf("lease after /new = %q, want %q", got, want)
355 }
356 lease, err := agent.TryAcquireSessionLease(active)
357 if err != nil {
358 t.Fatalf("old session lease not released by /new: %v", err)
359 }
360 lease.Release()
361 }
362
362 lines GO