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