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