返回 DeepSeek-Reasonix
checkpoints_cancode_test.go
根目录 / desktop / checkpoints_cancode_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strconv"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/checkpoint"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 )
17
18 func TestDesktopRewindCommitAndUndoUseAuthoritativeControllerState(t *testing.T) {
19 dir := t.TempDir()
20 root := t.TempDir()
21 sessionPath := filepath.Join(dir, "s.jsonl")
22 ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
23 if err := os.MkdirAll(ckptDir, 0o755); err != nil {
24 t.Fatal(err)
25 }
26 filePath := filepath.Join(root, "a.txt")
27 if err := os.WriteFile(filePath, []byte("after"), 0o644); err != nil {
28 t.Fatal(err)
29 }
30 fileInfo, err := os.Stat(filePath)
31 if err != nil {
32 t.Fatal(err)
33 }
34 diskMode := uint32(fileInfo.Mode().Perm())
35 before := "before"
36 afterExists := true
37 seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{
38 SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: time.Now(), Prompt: "edit", MsgIndex: 3,
39 Coverage: checkpoint.CoverageComplete,
40 Files: []checkpoint.FileSnap{{
41 Path: "a.txt", Content: &before, SHA256: checkpoint.Digest([]byte(before)), Mode: diskMode,
42 AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("after")), AfterMode: diskMode,
43 CaptureSource: checkpoint.CaptureBeforeMutation,
44 }},
45 })
46 session := agent.NewSession("")
47 session.Replace([]provider.Message{
48 {Role: provider.RoleSystem, Content: "sys"},
49 {Role: provider.RoleUser, Content: "first"},
50 {Role: provider.RoleAssistant, Content: "answer"},
51 {Role: provider.RoleUser, Content: "edit"},
52 {Role: provider.RoleAssistant, Content: "done"},
53 })
54 if err := session.Save(sessionPath); err != nil {
55 t.Fatal(err)
56 }
57 ag := agent.New(nil, nil, session, agent.Options{}, event.Discard)
58 ctrl := control.New(control.Options{Executor: ag, Runner: ag, SessionDir: dir, SessionPath: sessionPath, WorkspaceRoot: root, Label: "test"})
59 app := &App{}
60 app.setTestCtrl(ctrl, "test")
61
62 plan := app.PreviewRewindForTab("test", 1, "both")
63 if !plan.OK || !plan.CanFiles || !plan.CanConversation {
64 t.Fatalf("preview = %+v", plan)
65 }
66 result := app.CommitRewindForTab("test", plan.PlanID, 1, "both")
67 if !result.OK || !result.UndoAvailable || result.TransactionID == "" {
68 t.Fatalf("commit = %+v", result)
69 }
70 if got, err := os.ReadFile(filePath); err != nil || string(got) != before {
71 t.Fatalf("file after commit = %q err=%v", got, err)
72 }
73 if got := ctrl.History(); len(got) != 3 {
74 t.Fatalf("controller history after commit = %d, want 3", len(got))
75 }
76 if got := app.HistoryForTab("test"); len(got) != 3 {
77 t.Fatalf("desktop history after commit = %d, want 3", len(got))
78 }
79
80 undo := app.UndoRewindForTab("test", result.TransactionID)
81 if !undo.OK {
82 t.Fatalf("undo = %+v", undo)
83 }
84 if got, err := os.ReadFile(filePath); err != nil || string(got) != "after" {
85 t.Fatalf("file after undo = %q err=%v", got, err)
86 }
87 if got := ctrl.History(); len(got) != 5 {
88 t.Fatalf("controller history after undo = %d, want 5", len(got))
89 }
90 if got := app.HistoryForTab("test"); len(got) != 5 {
91 t.Fatalf("desktop history after undo = %d, want 5", len(got))
92 }
93 }
94
95 func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
96 t.Helper()
97 b, err := json.Marshal(c)
98 if err != nil {
99 t.Fatal(err)
100 }
101 if err := os.WriteFile(filepath.Join(ckptDir, "turn-"+strconv.Itoa(c.Turn)+".json"), b, 0o644); err != nil {
102 t.Fatal(err)
103 }
104 }
105
106 func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
107 t.Helper()
108 raw, err := json.Marshal(metas)
109 if err != nil {
110 t.Fatal(err)
111 }
112 var payload []struct {
113 Files json.RawMessage `json:"files"`
114 }
115 if err := json.Unmarshal(raw, &payload); err != nil {
116 t.Fatal(err)
117 }
118 for i, item := range payload {
119 if string(item.Files) == "null" {
120 t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
121 }
122 if len(item.Files) == 0 || item.Files[0] != '[' {
123 t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
124 }
125 }
126 }
127
128 // TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
129 // reverts files touched in that turn or any later one, so a turn with no file
130 // changes of its own can still rewind code when a later turn changed files. The
131 // desktop CanCode flag must reflect that suffix capability, not just the turn's
132 // own paths.
133 func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
134 dir := t.TempDir()
135 sessionPath := filepath.Join(dir, "s.jsonl")
136 ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
137 if err := os.MkdirAll(ckptDir, 0o755); err != nil {
138 t.Fatal(err)
139 }
140 content := "old"
141 afterExists := true
142 now := time.Now()
143 seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
144 seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
145 Coverage: checkpoint.CoverageComplete,
146 Files: []checkpoint.FileSnap{{
147 Path: "a.txt", Content: &content, SHA256: checkpoint.Digest([]byte(content)),
148 AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("new")), CaptureSource: checkpoint.CaptureBeforeMutation,
149 }}})
150 seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
151
152 ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
153 ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
154 ctrl.SetSessionPath(sessionPath)
155
156 app := &App{}
157 app.setTestCtrl(ctrl, "test")
158
159 metas := app.CheckpointsForTab("test")
160 if len(metas) != 3 {
161 t.Fatalf("checkpoints = %d, want 3", len(metas))
162 }
163 got := map[int]bool{}
164 for _, m := range metas {
165 got[m.Turn] = m.CanCode
166 }
167 if !got[0] {
168 t.Error("turn 0 (no files of its own) should allow code rewind — turn 1 changed files")
169 }
170 if !got[1] {
171 t.Error("turn 1 changed files, should allow code rewind")
172 }
173 if got[2] {
174 t.Error("turn 2 is after the last file-bearing turn, should NOT allow code rewind")
175 }
176 if metas[0].TurnFileCount != 0 {
177 t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
178 }
179 if metas[1].TurnFileCount != 1 {
180 t.Fatalf("turn 1 file count = %d, want 1 for this turn", metas[1].TurnFileCount)
181 }
182 if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
183 t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
184 }
185 if metas[0].FileCount != 1 || metas[0].FilesTruncated {
186 t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
187 }
188 if len(metas[2].Files) != 0 {
189 t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
190 }
191 assertCheckpointFilesEncodeAsArray(t, metas)
192 }
193
194 func TestCheckpointsCanCodeDoesNotReenableLegacySuffix(t *testing.T) {
195 dir := t.TempDir()
196 sessionPath := filepath.Join(dir, "s.jsonl")
197 ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
198 if err := os.MkdirAll(ckptDir, 0o755); err != nil {
199 t.Fatal(err)
200 }
201 content := "old"
202 now := time.Now()
203 seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "before", MsgIndex: 0})
204 seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "legacy edit", MsgIndex: 2,
205 Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
206
207 ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
208 ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
209 ctrl.SetSessionPath(sessionPath)
210 app := &App{}
211 app.setTestCtrl(ctrl, "test")
212
213 metas := app.CheckpointsForTab("test")
214 if len(metas) != 2 {
215 t.Fatalf("checkpoints = %d, want 2", len(metas))
216 }
217 for _, meta := range metas {
218 if meta.CanCode {
219 t.Fatalf("legacy suffix re-enabled code rewind at turn %d: %+v", meta.Turn, meta)
220 }
221 }
222 }
223
224 func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
225 dir := t.TempDir()
226 sessionPath := filepath.Join(dir, "s.jsonl")
227 ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
228 if err := os.MkdirAll(ckptDir, 0o755); err != nil {
229 t.Fatal(err)
230 }
231 content := "old"
232 files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
233 for i := 0; i < checkpointFilePreviewLimit+5; i++ {
234 files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
235 }
236 now := time.Now()
237 seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
238 seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
239
240 ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
241 ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
242 ctrl.SetSessionPath(sessionPath)
243
244 app := &App{}
245 app.setTestCtrl(ctrl, "test")
246
247 metas := app.CheckpointsForTab("test")
248 if len(metas) != 2 {
249 t.Fatalf("checkpoints = %d, want 2", len(metas))
250 }
251 if metas[0].FileCount != checkpointFilePreviewLimit+5 {
252 t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
253 }
254 if len(metas[0].Files) != checkpointFilePreviewLimit {
255 t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
256 }
257 if !metas[0].FilesTruncated {
258 t.Fatal("turn 0 should mark file preview as truncated")
259 }
260 if metas[0].TurnFileCount != 0 {
261 t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
262 }
263 if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
264 t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
265 }
266 assertCheckpointFilesEncodeAsArray(t, metas)
267 }
268
268 lines GO