返回 DeepSeek-Reasonix
branch_test.go
根目录 / internal / agent / branch_test.go
1 package agent
2
3 import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/provider"
12 )
13
14 func TestBranchMetaCrossProcessReadModifyWrite(t *testing.T) {
15 if os.Getenv("REASONIX_META_LOCK_HELPER") == "1" {
16 path := os.Getenv("REASONIX_META_LOCK_PATH")
17 unlock, err := LockSessionMetaPath(path)
18 if err != nil {
19 t.Fatal(err)
20 }
21 meta, err := EnsureBranchMetaLocked(path)
22 if err != nil {
23 unlock()
24 t.Fatal(err)
25 }
26 meta.Name = "written-by-peer"
27 if err := SaveBranchMetaPreserveUpdatedLocked(path, meta); err != nil {
28 unlock()
29 t.Fatal(err)
30 }
31 if err := os.WriteFile(os.Getenv("REASONIX_META_READY"), []byte("ready"), 0o600); err != nil {
32 unlock()
33 t.Fatal(err)
34 }
35 for {
36 if _, err := os.Stat(os.Getenv("REASONIX_META_RELEASE")); err == nil {
37 break
38 }
39 time.Sleep(5 * time.Millisecond)
40 }
41 unlock()
42 return
43 }
44
45 dir := t.TempDir()
46 path := filepath.Join(dir, "session.jsonl")
47 if err := SaveBranchMeta(path, BranchMeta{ID: "session"}); err != nil {
48 t.Fatal(err)
49 }
50 ready := filepath.Join(dir, "ready")
51 release := filepath.Join(dir, "release")
52 cmd := exec.Command(os.Args[0], "-test.run", "^TestBranchMetaCrossProcessReadModifyWrite$")
53 cmd.Env = append(os.Environ(),
54 "REASONIX_META_LOCK_HELPER=1",
55 "REASONIX_META_LOCK_PATH="+path,
56 "REASONIX_META_READY="+ready,
57 "REASONIX_META_RELEASE="+release,
58 )
59 if err := cmd.Start(); err != nil {
60 t.Fatal(err)
61 }
62 defer cmd.Wait()
63 deadline := time.Now().Add(5 * time.Second)
64 for {
65 if _, err := os.Stat(ready); err == nil {
66 break
67 }
68 if time.Now().After(deadline) {
69 t.Fatal("metadata helper did not acquire lock")
70 }
71 time.Sleep(5 * time.Millisecond)
72 }
73 done := make(chan error, 1)
74 go func() {
75 done <- UpdateBranchMeta(path, false, func(meta *BranchMeta) error {
76 meta.CustomTitle = "written-after-peer"
77 return nil
78 })
79 }()
80 time.Sleep(50 * time.Millisecond)
81 if err := os.WriteFile(release, []byte("release"), 0o600); err != nil {
82 t.Fatal(err)
83 }
84 if err := <-done; err != nil {
85 t.Fatal(err)
86 }
87 meta, ok, err := LoadBranchMeta(path)
88 if err != nil || !ok {
89 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
90 }
91 if meta.Name != "written-by-peer" || meta.CustomTitle != "written-after-peer" {
92 t.Fatalf("cross-process update lost a field: %+v", meta)
93 }
94 }
95
96 func TestPreserveBranchMetaPersistenceKeepsListingProjectionGenerationTogether(t *testing.T) {
97 existing := BranchMeta{
98 Revision: 2, ContentDigest: "new-digest", WriterID: "new-writer",
99 SchemaVersion: BranchMetaCountsVersion, Turns: 2, Preview: "new preview",
100 ListingRevision: 2, ListingContentDigest: "new-digest",
101 }
102 for _, next := range []BranchMeta{
103 {
104 Revision: 1, ContentDigest: "old-digest", WriterID: "old-writer",
105 SchemaVersion: BranchMetaCountsVersion, Turns: 1, Preview: "old preview",
106 ListingRevision: 1, ListingContentDigest: "old-digest",
107 },
108 {
109 Revision: 2, ContentDigest: "new-digest", WriterID: "new-writer",
110 SchemaVersion: BranchMetaCountsVersion, Turns: 1, Preview: "stale preview",
111 },
112 } {
113 preserveBranchMetaPersistence(&next, existing)
114 if next.Revision != existing.Revision || next.ContentDigest != existing.ContentDigest ||
115 next.SchemaVersion != existing.SchemaVersion || next.Turns != existing.Turns || next.Preview != existing.Preview ||
116 next.ListingRevision != existing.ListingRevision || next.ListingContentDigest != existing.ListingContentDigest {
117 t.Fatalf("projection generation split after preservation: got %+v want projection %+v", next, existing)
118 }
119 }
120 }
121
122 func TestBranchMetaIgnoresRetiredAutoRecoveryField(t *testing.T) {
123 dir := t.TempDir()
124 sessionPath := filepath.Join(dir, "legacy.jsonl")
125 metaPath := BranchMetaPath(sessionPath)
126 legacy := `{"id":"legacy","name":"kept","recovery_checkpoint_enabled":false}`
127 if err := os.WriteFile(metaPath, []byte(legacy), 0o600); err != nil {
128 t.Fatalf("write legacy branch meta: %v", err)
129 }
130
131 meta, ok, err := LoadBranchMeta(sessionPath)
132 if err != nil || !ok {
133 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
134 }
135 if meta.Name != "kept" {
136 t.Fatalf("name = %q, want kept", meta.Name)
137 }
138 if err := SaveBranchMeta(sessionPath, meta); err != nil {
139 t.Fatalf("SaveBranchMeta: %v", err)
140 }
141 written, err := os.ReadFile(metaPath)
142 if err != nil {
143 t.Fatalf("read rewritten branch meta: %v", err)
144 }
145 if strings.Contains(string(written), "recovery_checkpoint_enabled") {
146 t.Fatalf("retired recovery field survived rewrite: %s", written)
147 }
148 }
149
150 func TestBranchMetaRoundTripAndList(t *testing.T) {
151 dir := t.TempDir()
152 rootPath := filepath.Join(dir, "root.jsonl")
153 childPath := filepath.Join(dir, "child.jsonl")
154
155 root := NewSession("sys")
156 root.Add(provider.Message{Role: provider.RoleUser, Content: "root prompt"})
157 if err := root.Save(rootPath); err != nil {
158 t.Fatal(err)
159 }
160 if err := TouchBranchMeta(rootPath); err != nil {
161 t.Fatal(err)
162 }
163
164 child := NewSession("sys")
165 child.Add(provider.Message{Role: provider.RoleUser, Content: "child prompt"})
166 if err := child.Save(childPath); err != nil {
167 t.Fatal(err)
168 }
169 if err := SaveBranchMeta(childPath, BranchMeta{Name: "experiment", ParentID: BranchID(rootPath), ForkTurn: 2}); err != nil {
170 t.Fatal(err)
171 }
172
173 branches, err := ListBranches(dir)
174 if err != nil {
175 t.Fatal(err)
176 }
177 if len(branches) != 2 {
178 t.Fatalf("branches = %d, want 2", len(branches))
179 }
180 var rootFound, childFound bool
181 for _, b := range branches {
182 if b.ID == "root" {
183 rootFound = true
184 }
185 if b.ParentID == "root" && b.Name == "experiment" {
186 childFound = true
187 }
188 }
189 if !rootFound {
190 t.Fatal("root branch not found")
191 }
192 if !childFound {
193 t.Fatalf("child with parent root and name experiment not found among %+v", branches)
194 }
195 }
196
197 func TestBranchMetaEffectiveVersionKindKeepsLegacyRecoveryReadable(t *testing.T) {
198 if got := (BranchMeta{Recovered: true}).EffectiveVersionKind(); got != VersionRecovery {
199 t.Fatalf("legacy recovered kind = %q, want %q", got, VersionRecovery)
200 }
201 if got := (BranchMeta{}).EffectiveVersionKind(); got != VersionNormal {
202 t.Fatalf("legacy normal kind = %q, want %q", got, VersionNormal)
203 }
204 if got := (BranchMeta{}).EffectiveVersionState(); got != VersionActive {
205 t.Fatalf("legacy version state = %q, want %q", got, VersionActive)
206 }
207 meta := BranchMeta{VersionKind: VersionSubagent, VersionState: VersionPending}
208 if meta.EffectiveVersionKind() != VersionSubagent || meta.EffectiveVersionState() != VersionPending {
209 t.Fatalf("explicit version identity was not preserved: %+v", meta)
210 }
211 }
212
213 func TestListBranchesSkipsCleanupPending(t *testing.T) {
214 dir := t.TempDir()
215 visiblePath := filepath.Join(dir, "visible.jsonl")
216 pendingPath := filepath.Join(dir, "pending.jsonl")
217
218 visible := NewSession("sys")
219 visible.Add(provider.Message{Role: provider.RoleUser, Content: "visible prompt"})
220 if err := visible.Save(visiblePath); err != nil {
221 t.Fatal(err)
222 }
223 if err := TouchBranchMeta(visiblePath); err != nil {
224 t.Fatal(err)
225 }
226
227 pending := NewSession("sys")
228 pending.Add(provider.Message{Role: provider.RoleUser, Content: "pending prompt"})
229 if err := pending.Save(pendingPath); err != nil {
230 t.Fatal(err)
231 }
232 if err := SaveBranchMeta(pendingPath, BranchMeta{Name: "pending experiment"}); err != nil {
233 t.Fatal(err)
234 }
235 if err := MarkCleanupPending(pendingPath, "delete"); err != nil {
236 t.Fatal(err)
237 }
238
239 branches, err := ListBranches(dir)
240 if err != nil {
241 t.Fatal(err)
242 }
243 if len(branches) != 1 {
244 t.Fatalf("branches = %d, want 1: %+v", len(branches), branches)
245 }
246 if branches[0].Path != visiblePath {
247 t.Fatalf("listed branch path = %q, want %q", branches[0].Path, visiblePath)
248 }
249 }
250
251 func TestSessionInFlightTurnMetaRoundTrip(t *testing.T) {
252 dir := t.TempDir()
253 path := filepath.Join(dir, "in-flight.jsonl")
254 sess := NewSession("sys")
255 sess.Add(provider.Message{Role: provider.RoleUser, Content: "work"})
256 if err := sess.Save(path); err != nil {
257 t.Fatal(err)
258 }
259 if err := TouchBranchMeta(path); err != nil {
260 t.Fatal(err)
261 }
262 before, ok, err := LoadBranchMeta(path)
263 if err != nil || !ok {
264 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
265 }
266 updatedAt := before.UpdatedAt
267
268 if err := MarkSessionInFlightTurn(path, 1, true); err != nil {
269 t.Fatal(err)
270 }
271 marked, ok, err := LoadBranchMeta(path)
272 if err != nil || !ok {
273 t.Fatalf("LoadBranchMeta marked ok=%v err=%v", ok, err)
274 }
275 if marked.InFlightTurn == nil {
276 t.Fatal("in-flight turn marker missing")
277 }
278 if marked.InFlightTurn.ID == "" || marked.InFlightTurn.StartRevision == 0 || marked.InFlightTurn.StartDigest == "" {
279 t.Fatalf("marker identity = %+v, want id/revision/digest", marked.InFlightTurn)
280 }
281 if marked.InFlightTurn.StartMessageIndex != 1 || !marked.InFlightTurn.PreserveUser {
282 t.Fatalf("in-flight marker = %+v, want index=1 preserveUser=true", marked.InFlightTurn)
283 }
284 if marked.InFlightTurn.StartedAt.IsZero() || time.Since(marked.InFlightTurn.StartedAt) > time.Minute {
285 t.Fatalf("unexpected marker timestamp: %v", marked.InFlightTurn.StartedAt)
286 }
287 if !marked.UpdatedAt.Equal(updatedAt) {
288 t.Fatalf("MarkSessionInFlightTurn updated activity time: got %v want %v", marked.UpdatedAt, updatedAt)
289 }
290 oldMarker := *marked.InFlightTurn
291 newMarker, err := BeginSessionInFlightTurn(path, 2, false)
292 if err != nil {
293 t.Fatal(err)
294 }
295 if cleared, err := ClearSessionInFlightTurnIfMatch(path, oldMarker); err != nil {
296 t.Fatal(err)
297 } else if cleared {
298 t.Fatal("stale marker unexpectedly cleared a newer marker")
299 }
300 current, ok, err := LoadBranchMeta(path)
301 if err != nil || !ok || current.InFlightTurn == nil || current.InFlightTurn.ID != newMarker.ID {
302 t.Fatalf("new marker after stale clear = %+v ok=%v err=%v", current.InFlightTurn, ok, err)
303 }
304
305 if err := UpdateSessionMeta(path, "model-a", "preview", 1, true); err != nil {
306 t.Fatal(err)
307 }
308 refreshed, ok, err := LoadBranchMeta(path)
309 if err != nil || !ok {
310 t.Fatalf("LoadBranchMeta refreshed ok=%v err=%v", ok, err)
311 }
312 if refreshed.InFlightTurn == nil {
313 t.Fatal("UpdateSessionMeta dropped in-flight marker")
314 }
315 if refreshed.InFlightTurn.StartMessageIndex != 2 || refreshed.InFlightTurn.PreserveUser {
316 t.Fatalf("refreshed in-flight marker = %+v, want index=2 preserveUser=false", refreshed.InFlightTurn)
317 }
318 updatedAt = refreshed.UpdatedAt
319
320 if _, err := ClearSessionInFlightTurnIfMatch(path, newMarker); err != nil {
321 t.Fatal(err)
322 }
323 cleared, ok, err := LoadBranchMeta(path)
324 if err != nil || !ok {
325 t.Fatalf("LoadBranchMeta cleared ok=%v err=%v", ok, err)
326 }
327 if cleared.InFlightTurn != nil {
328 t.Fatalf("in-flight marker survived clear: %+v", cleared.InFlightTurn)
329 }
330 if !cleared.UpdatedAt.Equal(updatedAt) {
331 t.Fatalf("ClearSessionInFlightTurn updated activity time: got %v want %v", cleared.UpdatedAt, updatedAt)
332 }
333 }
334
335 func TestUpdateBranchMetaWithoutTouchUsesFileMtimeForZeroUpdatedAt(t *testing.T) {
336 dir := t.TempDir()
337 path := filepath.Join(dir, "session.jsonl")
338 when := time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC)
339 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
340 t.Fatal(err)
341 }
342 if err := os.Chtimes(path, when, when); err != nil {
343 t.Fatal(err)
344 }
345 if err := os.WriteFile(BranchMetaPath(path), []byte(`{"id":"session","created_at":"2026-02-03T04:05:06Z","updated_at":"0001-01-01T00:00:00Z"}`+"\n"), 0o600); err != nil {
346 t.Fatal(err)
347 }
348 if err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error {
349 meta.TopicID = "topic"
350 return nil
351 }); err != nil {
352 t.Fatal(err)
353 }
354 got, ok, err := LoadBranchMeta(path)
355 if err != nil || !ok {
356 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
357 }
358 if !got.UpdatedAt.Equal(when.UTC()) {
359 t.Fatalf("UpdatedAt = %v, want file mtime %v (listing writes must not mint wall-clock activity)", got.UpdatedAt, when.UTC())
360 }
361 if got.TopicID != "topic" {
362 t.Fatalf("TopicID = %q, want topic", got.TopicID)
363 }
364 }
365
366 func TestSessionModelRoundTripPreservesActivity(t *testing.T) {
367 dir := t.TempDir()
368 path := filepath.Join(dir, "session.jsonl")
369 session := NewSession("sys")
370 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
371 if err := session.Save(path); err != nil {
372 t.Fatal(err)
373 }
374 if _, ok := LoadSessionModel(path); ok {
375 t.Fatal("fresh session should not have a stored model")
376 }
377 meta, err := EnsureBranchMeta(path)
378 if err != nil {
379 t.Fatal(err)
380 }
381
382 if err := SetBranchModelPreserveUpdated(path, "openrouter/anthropic/claude-sonnet"); err != nil {
383 t.Fatal(err)
384 }
385 model, ok := LoadSessionModel(path)
386 if !ok || model != "openrouter/anthropic/claude-sonnet" {
387 t.Fatalf("LoadSessionModel = %q, %v", model, ok)
388 }
389 updated, ok, err := LoadBranchMeta(path)
390 if err != nil || !ok {
391 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
392 }
393 if !updated.UpdatedAt.Equal(meta.UpdatedAt) {
394 t.Fatalf("model write refreshed activity: before=%s after=%s", meta.UpdatedAt, updated.UpdatedAt)
395 }
396 }
397
397 lines GO