返回 DeepSeek-Reasonix
save_false_diverged_test.go
根目录 / internal / agent / save_false_diverged_test.go
1 package agent
2
3 import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/provider"
12 )
13
14 func recoveryJSONL(dir string) []string {
15 matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
16 if err != nil {
17 return nil
18 }
19 var out []string
20 for _, m := range matches {
21 if strings.HasSuffix(m, ".events.jsonl") {
22 continue
23 }
24 out = append(out, m)
25 }
26 return out
27 }
28
29 // #8294 growth shape: pure append autosaves must never create recovery files.
30 func TestSaveSnapshotStreamingAppendDoesNotDiverge(t *testing.T) {
31 dir := t.TempDir()
32 path := filepath.Join(dir, "session.jsonl")
33 s := NewSession("sys")
34 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
35 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi", ReasoningContent: "think"})
36 if err := s.SaveSnapshot(path); err != nil {
37 t.Fatalf("initial save: %v", err)
38 }
39 for i := range 20 {
40 s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("u%d", i)})
41 s.Add(provider.Message{
42 Role: provider.RoleAssistant, Content: fmt.Sprintf("a%d", i),
43 ReasoningContent: fmt.Sprintf("r%d", i),
44 ToolCalls: []provider.ToolCall{{ID: fmt.Sprintf("c%d", i), Name: "bash", Arguments: `{"cmd":"true"}`}},
45 })
46 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: fmt.Sprintf("c%d", i), Name: "bash", Content: "ok", WorkDurationMs: int64(i)})
47 s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("done%d", i), ReasoningContent: "more"})
48 if err := s.SaveSnapshot(path); err != nil {
49 t.Fatalf("SaveSnapshot turn %d: %v", i, err)
50 }
51 }
52 if got := recoveryJSONL(dir); len(got) != 0 {
53 t.Fatalf("recovery branches during pure append: %v", got)
54 }
55 }
56
57 // Authority + same revision authorizes rewrite when the shared prefix was reshaped (#8294).
58 func TestSaveSnapshotLeaseHeldSameRevisionAllowsReshapedPrefix(t *testing.T) {
59 dir := schemaOneTempDir(t)
60 path := filepath.Join(dir, "session.jsonl")
61 s := NewSession("sys")
62 s.Add(provider.Message{Role: provider.RoleUser, Content: "edit"})
63 s.Add(provider.Message{
64 Role: provider.RoleAssistant, Content: "editing",
65 ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "edit", Arguments: `{"path":"f"}`}},
66 })
67 if err := s.SaveSnapshot(path); err != nil {
68 t.Fatalf("mid-turn: %v", err)
69 }
70
71 lease, err := TryAcquireSessionLease(path)
72 if err != nil {
73 t.Fatalf("TryAcquireSessionLease: %v", err)
74 }
75 defer lease.Release()
76 auth, err := lease.IssueWriteAuthority(NextSessionWriteGeneration())
77 if err != nil {
78 t.Fatalf("IssueWriteAuthority: %v", err)
79 }
80 s.BindWriteAuthority(auth)
81
82 // Disk reshape at same revision (digest ownership fails; lease authorizes).
83 foreign := NewSession("sys")
84 foreign.Add(provider.Message{Role: provider.RoleUser, Content: "edit"})
85 foreign.Add(provider.Message{
86 Role: provider.RoleAssistant, Content: "editing",
87 ToolCalls: []provider.ToolCall{{
88 ID: "call_1", Name: "edit", Arguments: `{"path":"f"}`,
89 Diff: "@@ -1 +1 @@\n-a\n+b\n", Added: 1, Removed: 1,
90 }},
91 })
92 foreignMsgs := foreign.Snapshot()
93 foreignDigest, err := digestSessionMessages(foreignMsgs)
94 if err != nil {
95 t.Fatalf("digest: %v", err)
96 }
97 revision, _, err := sessionContentRevision(path)
98 if err != nil {
99 t.Fatalf("sessionContentRevision: %v", err)
100 }
101 if err := appendSessionReplaceEvent(path, foreignMsgs, foreignDigest, revision, "snapshot"); err != nil {
102 t.Fatalf("append foreign reshape: %v", err)
103 }
104 if err := writeSessionMessages(path, foreignMsgs); err != nil {
105 t.Fatalf("write foreign reshape: %v", err)
106 }
107 // Intentionally leave the revision ledger at the original value.
108
109 if !s.UpdateToolCallPreview(provider.ToolCall{ID: "call_1", Diff: "@@ -1 +1 @@\n-a\n+b\n", Added: 1, Removed: 1}) {
110 t.Fatal("preview update failed")
111 }
112 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "call_1", Name: "edit", Content: "ok"})
113 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done", ReasoningContent: "r"})
114 if err := s.SaveSnapshot(path); err != nil {
115 t.Fatalf("SaveSnapshot with lease after reshape: %v", err)
116 }
117 if got := recoveryJSONL(dir); len(got) != 0 {
118 t.Fatalf("unexpected recovery branches: %v", got)
119 }
120
121 // Keep the 30s autosave cadence growing the transcript on the same path.
122 for i := range 10 {
123 s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("n%d", i)})
124 s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("r%d", i)})
125 if err := s.SaveSnapshot(path); err != nil {
126 t.Fatalf("continue %d: %v", i, err)
127 }
128 }
129 if got := recoveryJSONL(dir); len(got) != 0 {
130 t.Fatalf("recovery branches after continued autosaves: %v", got)
131 }
132 }
133
134 // After an intentional recovery retarget, further appends must not cascade.
135 func TestSaveSnapshotChainAfterRecoveryForkDoesNotCascade(t *testing.T) {
136 dir := t.TempDir()
137 path := filepath.Join(dir, "session.jsonl")
138 s := NewSession("sys")
139 s.Add(provider.Message{Role: provider.RoleUser, Content: "start"})
140 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
141 if err := s.Save(path); err != nil {
142 t.Fatalf("base: %v", err)
143 }
144
145 s.Add(provider.Message{Role: provider.RoleUser, Content: "unsaved"})
146 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "local only tail"})
147 info, err := s.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path, Reason: "snapshot conflict"})
148 if err != nil {
149 t.Fatalf("SaveRecoveryBranch: %v", err)
150 }
151 for i := range 15 {
152 s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("u%d", i)})
153 s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("a%d", i), ReasoningContent: "x"})
154 if err := s.SaveSnapshot(info.Path); err != nil {
155 var conflict *SessionSnapshotConflictError
156 if errors.As(err, &conflict) {
157 t.Fatalf("SaveSnapshot on recovery path turn %d diverged: %+v", i, conflict)
158 }
159 t.Fatalf("SaveSnapshot turn %d: %v", i, err)
160 }
161 }
162 if got := recoveryJSONL(dir); len(got) != 1 {
163 t.Fatalf("recovery files = %v (want only the intentional fork)", got)
164 }
165 }
166
167 // Without a lease, foreign bytes at the same revision still conflict.
168 func TestSaveSnapshotRejectsInterruptedForeignWriteWithoutLease(t *testing.T) {
169 path := schemaOneSessionPath(t, "session.jsonl")
170 base := NewSession("sys")
171 base.Add(provider.Message{Role: provider.RoleUser, Content: "base"})
172 if err := base.Save(path); err != nil {
173 t.Fatal(err)
174 }
175 stale, err := LoadSession(path)
176 if err != nil {
177 t.Fatal(err)
178 }
179 revision, _, err := sessionContentRevision(path)
180 if err != nil {
181 t.Fatal(err)
182 }
183 foreignMessages := append(stale.Snapshot(),
184 provider.Message{Role: provider.RoleAssistant, Content: "foreign writer tail"})
185 foreignDigest, err := digestSessionMessages(foreignMessages)
186 if err != nil {
187 t.Fatal(err)
188 }
189 if err := appendSessionReplaceEvent(path, foreignMessages, foreignDigest, revision, "snapshot"); err != nil {
190 t.Fatal(err)
191 }
192 if err := writeSessionMessages(path, foreignMessages); err != nil {
193 t.Fatal(err)
194 }
195 // Crash before recordSessionContentRevision: revision still equals baseline.
196
197 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "stale writer tail"})
198 err = stale.SaveSnapshot(path)
199 if !errors.Is(err, ErrSessionSnapshotConflict) {
200 t.Fatalf("SaveSnapshot err = %v, want ErrSessionSnapshotConflict", err)
201 }
202 }
203
204 // Authority missing after bind: typed error, zero recovery.
205 func TestSaveSnapshotAuthorityMissingReturnsTypedError(t *testing.T) {
206 dir := t.TempDir()
207 path := filepath.Join(dir, "session.jsonl")
208 s := NewSession("sys")
209 s.Add(provider.Message{Role: provider.RoleUser, Content: "u"})
210 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "a"})
211 if err := s.SaveSnapshot(path); err != nil {
212 t.Fatal(err)
213 }
214 // Simulate a bound-then-cleared controller without rebind.
215 s.BindWriteAuthority(&SessionWriteAuthority{}) // forces authRequired
216 s.ClearWriteAuthority()
217 s.Add(provider.Message{Role: provider.RoleUser, Content: "more"})
218 err := s.SaveSnapshot(path)
219 if !errors.Is(err, ErrSessionWriteAuthorityMissing) {
220 t.Fatalf("err = %v, want ErrSessionWriteAuthorityMissing", err)
221 }
222 if got := recoveryJSONL(dir); len(got) != 0 {
223 t.Fatalf("recovery files = %v, want none", got)
224 }
225 }
226
227 // Stale generation after rebind refuses save without forking recovery.
228 func TestSaveSnapshotStaleAuthorityRefused(t *testing.T) {
229 dir := t.TempDir()
230 path := filepath.Join(dir, "session.jsonl")
231 s := NewSession("sys")
232 s.Add(provider.Message{Role: provider.RoleUser, Content: "u"})
233 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "a"})
234 if err := s.SaveSnapshot(path); err != nil {
235 t.Fatal(err)
236 }
237 lease, err := TryAcquireSessionLease(path)
238 if err != nil {
239 t.Fatal(err)
240 }
241 defer lease.Release()
242 oldAuth, err := lease.IssueWriteAuthority(NextSessionWriteGeneration())
243 if err != nil {
244 t.Fatal(err)
245 }
246 s.BindWriteAuthority(oldAuth)
247 // New generation supersedes the old token without releasing the lease.
248 newAuth, err := lease.IssueWriteAuthority(NextSessionWriteGeneration())
249 if err != nil {
250 t.Fatal(err)
251 }
252 if !newAuth.Valid() {
253 t.Fatal("replacement authority should be valid")
254 }
255 s.Add(provider.Message{Role: provider.RoleUser, Content: "more"})
256 s.BindWriteAuthority(oldAuth)
257 err = s.SaveSnapshot(path)
258 if !errors.Is(err, ErrSessionWriteAuthorityStale) {
259 t.Fatalf("err = %v, want stale authority", err)
260 }
261 if got := recoveryJSONL(dir); len(got) != 0 {
262 t.Fatalf("recovery files = %v, want none", got)
263 }
264 }
265
266 // 0-byte checkpoint + valid WAL: continuous autosave heals and never recovery-forks.
267 func TestSaveSnapshotZeroByteCheckpointHealsFromWAL(t *testing.T) {
268 dir := t.TempDir()
269 path := filepath.Join(dir, "session.jsonl")
270 s := NewSession("sys")
271 s.Add(provider.Message{Role: provider.RoleUser, Content: "u0"})
272 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "a0"})
273 if err := s.SaveSnapshot(path); err != nil {
274 t.Fatal(err)
275 }
276 leas, err := TryAcquireSessionLease(path)
277 if err != nil {
278 t.Fatal(err)
279 }
280 defer leas.Release()
281 auth, err := leas.IssueWriteAuthority(NextSessionWriteGeneration())
282 if err != nil {
283 t.Fatal(err)
284 }
285 s.BindWriteAuthority(auth)
286 // Truncate the checkpoint while leaving the event log intact.
287 if err := os.WriteFile(path, nil, 0o644); err != nil {
288 t.Fatal(err)
289 }
290 for i := range 100 {
291 s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("u%d", i+1)})
292 s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("a%d", i+1)})
293 if err := s.SaveSnapshot(path); err != nil {
294 t.Fatalf("autosave %d: %v", i, err)
295 }
296 }
297 if got := recoveryJSONL(dir); len(got) != 0 {
298 t.Fatalf("recovery files = %v, want none", got)
299 }
300 info, err := os.Stat(path)
301 if err != nil || info.Size() == 0 {
302 t.Fatalf("checkpoint size = %v err=%v, want healed non-empty", info, err)
303 }
304 }
305
305 lines GO