返回 DeepSeek-Reasonix
rewind_e2e_test.go
根目录 / internal / control / rewind_e2e_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/checkpoint"
15 "reasonix/internal/event"
16 "reasonix/internal/provider"
17 "reasonix/internal/tool"
18 )
19
20 func TestCompatibilityRewindRequiresConfirmationForPartialCoverage(t *testing.T) {
21 dir := t.TempDir()
22 root := t.TempDir()
23 path := filepath.Join(root, "partial.txt")
24 if err := os.WriteFile(path, []byte("before"), 0o644); err != nil {
25 t.Fatal(err)
26 }
27 sess := agent.NewSession("sys")
28 ag := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
29 c := New(Options{
30 Runner: ag,
31 Executor: ag,
32 SessionDir: dir,
33 SessionPath: filepath.Join(dir, "partial.jsonl"),
34 WorkspaceRoot: root,
35 Sink: event.Discard,
36 })
37 c.beginCheckpoint("edit partial.txt")
38 c.mutationObserver.BeforeMutation("partial.txt", "write_file", checkpoint.CaptureBeforeMutation)
39 if err := os.WriteFile(path, []byte("after"), 0o644); err != nil {
40 t.Fatal(err)
41 }
42 c.mutationObserver.AfterMutation("partial.txt", "write_file")
43 c.mutationObserver.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapBashSideEffect, Tool: "bash"})
44
45 plan, err := c.PrepareRewind(0, RewindCode)
46 if err != nil {
47 t.Fatal(err)
48 }
49 if !plan.CanFiles || !RewindPlanRequiresConfirmation(plan) {
50 t.Fatalf("partial plan = %+v, want restorable files with explicit confirmation", plan)
51 }
52 if err := c.Rewind(0, RewindCode); !errors.Is(err, ErrRewindCoverageConfirmationRequired) {
53 t.Fatalf("compatibility Rewind error = %v, want confirmation-required", err)
54 }
55 if got := string(mustReadFile(t, path)); got != "after" {
56 t.Fatalf("unconfirmed rewind changed file to %q", got)
57 }
58
59 result, err := c.CommitRewind(plan.PlanID)
60 if err != nil || !result.OK {
61 t.Fatalf("confirmed CommitRewind result=%+v err=%v", result, err)
62 }
63 if got := string(mustReadFile(t, path)); got != "before" {
64 t.Fatalf("confirmed rewind left file at %q, want before", got)
65 }
66 }
67
68 func TestResumeRecoversCommittingCombinedRewind(t *testing.T) {
69 dir := t.TempDir()
70 root := t.TempDir()
71 sessionPath := filepath.Join(dir, "session.jsonl")
72 filePath := filepath.Join(root, "a.txt")
73 if err := os.WriteFile(filePath, []byte("before"), 0o644); err != nil {
74 t.Fatal(err)
75 }
76 fileInfo, err := os.Stat(filePath)
77 if err != nil {
78 t.Fatal(err)
79 }
80 diskMode := uint32(fileInfo.Mode().Perm())
81 fullMessages := []provider.Message{
82 {Role: provider.RoleSystem, Content: "sys"},
83 {Role: provider.RoleUser, Content: "first"},
84 {Role: provider.RoleAssistant, Content: "answer"},
85 {Role: provider.RoleUser, Content: "second"},
86 {Role: provider.RoleAssistant, Content: "later"},
87 }
88 saved := agent.NewSession("")
89 saved.Replace(fullMessages[:3])
90 if err := saved.Save(sessionPath); err != nil {
91 t.Fatal(err)
92 }
93 forward, err := json.Marshal(fullMessages)
94 if err != nil {
95 t.Fatal(err)
96 }
97 checkpointBackup, err := json.Marshal([]*checkpoint.Checkpoint{{
98 SchemaVersion: checkpoint.SchemaV2,
99 Turn: 1,
100 Prompt: "second",
101 MsgIndex: 3,
102 }})
103 if err != nil {
104 t.Fatal(err)
105 }
106 checkpointDir := ckptDir(sessionPath)
107 if err := os.MkdirAll(filepath.Join(checkpointDir, "transactions"), 0o755); err != nil {
108 t.Fatal(err)
109 }
110 tx := checkpoint.TransactionManifest{
111 SchemaVersion: checkpoint.SchemaV2,
112 ID: "tx-resume-recovery",
113 WorkspaceRoot: root,
114 State: checkpoint.TxCommitting,
115 Kind: "rewind",
116 Turn: 1,
117 Scope: checkpoint.RewindBoth,
118 HasBoundary: true,
119 BoundaryIndex: 3,
120 TruncateFrom: 1,
121 ConversationForward: forward,
122 CheckpointBackup: checkpointBackup,
123 Targets: []checkpoint.TransactionTarget{{
124 Path: "a.txt", AbsPath: filePath, Action: "write", Published: true,
125 RestoreExisted: true, RestoreSHA: checkpoint.Digest([]byte("before")), RestoreMode: diskMode,
126 ForwardExisted: true, ForwardSHA: checkpoint.Digest([]byte("after")), ForwardMode: diskMode,
127 ForwardInline: []byte("after"), BackupPath: filepath.Join(root, ".a.txt.reasonix-recovery.bak"),
128 }},
129 }
130 raw, err := json.Marshal(tx)
131 if err != nil {
132 t.Fatal(err)
133 }
134 manifestPath := filepath.Join(checkpointDir, "transactions", tx.ID+".json")
135 if err := os.WriteFile(manifestPath, raw, 0o644); err != nil {
136 t.Fatal(err)
137 }
138
139 loaded, err := agent.LoadSession(sessionPath)
140 if err != nil {
141 t.Fatal(err)
142 }
143 ag := agent.New(nil, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
144 c := New(Options{Executor: ag, Runner: ag, SessionDir: dir, WorkspaceRoot: root})
145 c.Resume(loaded, sessionPath)
146 if got := ag.Session().Snapshot(); len(got) != len(fullMessages) || got[len(got)-1].Content != "later" {
147 t.Fatalf("recovered conversation = %#v, want full forward transcript", got)
148 }
149 data, err := os.ReadFile(filePath)
150 if err != nil {
151 t.Fatal(err)
152 }
153 if string(data) != "after" {
154 t.Fatalf("recovered file = %q, want after", data)
155 }
156 if got := c.Checkpoints(); len(got) != 1 || got[0].Turn != 1 {
157 t.Fatalf("recovered checkpoints = %+v, want turn 1", got)
158 }
159 if err := json.Unmarshal(mustReadFile(t, manifestPath), &tx); err != nil {
160 t.Fatal(err)
161 }
162 if tx.State != checkpoint.TxAborted {
163 t.Fatalf("transaction state = %s, want aborted", tx.State)
164 }
165 }
166
167 func mustReadFile(t *testing.T, path string) []byte {
168 t.Helper()
169 b, err := os.ReadFile(path)
170 if err != nil {
171 t.Fatal(err)
172 }
173 return b
174 }
175
176 func runTwoTurns(t *testing.T) (*Controller, *agent.Agent, *[]event.Event) {
177 t.Helper()
178 dir := t.TempDir()
179 prov := &scriptedTurns{turns: [][]provider.Chunk{
180 textTurn("first answer"),
181 textTurn("second answer"),
182 textTurn("edited answer"),
183 }}
184 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
185 var events []event.Event
186 c := New(Options{
187 Runner: ag,
188 Executor: ag,
189 SessionDir: dir,
190 Label: "test",
191 Sink: event.FuncSink(func(e event.Event) { events = append(events, e) }),
192 })
193 c.SetSessionPath(agent.NewSessionPath(dir, "test"))
194 if err := c.runTurnWithRaw(context.Background(), "first prompt", "first prompt"); err != nil {
195 t.Fatalf("turn 1: %v", err)
196 }
197 if err := c.runTurnWithRaw(context.Background(), "second prompt", "second prompt"); err != nil {
198 t.Fatalf("turn 2: %v", err)
199 }
200 return c, ag, &events
201 }
202
203 // TestRewindConversationFailsLoudlyAfterCompaction reproduces #3598: once
204 // compaction shrinks the message log below a turn's recorded boundary, a
205 // conversation/both rewind to that turn skipped the truncation but still emitted
206 // a success notice — code rolled back, conversation silently did not.
207 func TestRewindConversationFailsLoudlyAfterCompaction(t *testing.T) {
208 c, ag, events := runTwoTurns(t)
209
210 c.checkpoints.mu.Lock()
211 lastTurn := c.checkpoints.turn - 1
212 boundary := c.checkpoints.bound[lastTurn]
213 c.checkpoints.mu.Unlock()
214 if boundary <= 1 {
215 t.Fatalf("expected the latest turn's boundary above 1, got bound=%v", c.checkpoints.bound)
216 }
217
218 // Auto-compaction replaces the prefix with a summary, shrinking the log below
219 // the recorded boundary; compaction does not rewrite checkpoint boundaries.
220 sess := ag.Session()
221 sess.Messages = []provider.Message{{Role: provider.RoleUser, Content: "summary"}}
222
223 *events = nil
224 err := c.Rewind(lastTurn, RewindBoth)
225 if err == nil || !strings.Contains(err.Error(), "compacted") {
226 t.Fatalf("Rewind after compaction error = %v, want a 'compacted past' failure", err)
227 }
228 for _, e := range *events {
229 if e.Kind == event.Notice && strings.Contains(e.Text, "rewound conversation") {
230 t.Fatalf("emitted a false conversation-rewind success after skipping truncation: %q", e.Text)
231 }
232 }
233 if got := len(ag.Session().Messages); got != 1 {
234 t.Fatalf("session messages = %d, want the compacted log left intact at 1", got)
235 }
236 }
237
238 // TestRewindConversationSucceedsWithLiveBoundary is the companion happy path: a
239 // boundary still within the log truncates the conversation and reports success.
240 func TestRewindConversationSucceedsWithLiveBoundary(t *testing.T) {
241 c, ag, events := runTwoTurns(t)
242
243 c.checkpoints.mu.Lock()
244 lastTurn := c.checkpoints.turn - 1
245 boundary := c.checkpoints.bound[lastTurn]
246 c.checkpoints.mu.Unlock()
247
248 *events = nil
249 if err := c.Rewind(lastTurn, RewindConversation); err != nil {
250 t.Fatalf("Rewind with a live boundary: %v", err)
251 }
252 if got := len(ag.Session().Messages); got != boundary {
253 t.Fatalf("session truncated to %d messages, want boundary %d", got, boundary)
254 }
255 ok := false
256 for _, e := range *events {
257 if e.Kind == event.Notice && strings.Contains(e.Text, "rewound conversation") {
258 ok = true
259 }
260 }
261 if !ok {
262 t.Fatal("expected a conversation-rewind success notice")
263 }
264 }
265
266 func TestEditPromptPersistsOriginalPrompt(t *testing.T) {
267 c, ag, _ := runTwoTurns(t)
268
269 if err := c.Rewind(1, RewindConversation); err != nil {
270 t.Fatal(err)
271 }
272 c.SubmitEditedDisplay("edited prompt", "edited prompt", "second prompt")
273 defer c.autosaveWG.Wait()
274
275 var loaded *agent.Session
276 deadline := time.Now().Add(time.Second)
277 for {
278 var err error
279 loaded, err = agent.LoadSession(c.SessionPath())
280 if err == nil {
281 msgs := loaded.Snapshot()
282 if len(msgs) >= 2 {
283 last := msgs[len(msgs)-2]
284 if last.Role == provider.RoleUser && last.Content == "edited prompt" {
285 break
286 }
287 }
288 }
289 if time.Now().After(deadline) {
290 t.Fatalf("edited prompt was not persisted before deadline")
291 }
292 time.Sleep(10 * time.Millisecond)
293 }
294 msgs := loaded.Snapshot()
295 last := msgs[len(msgs)-2]
296 if last.Role != provider.RoleUser || last.Content != "edited prompt" {
297 t.Fatalf("last user message = %+v, want edited prompt", last)
298 }
299 if !last.Edited || last.Original != "second prompt" {
300 t.Fatalf("edit metadata = edited:%v original:%q, want edited:true original:%q", last.Edited, last.Original, "second prompt")
301 }
302 for _, m := range ag.Session().Snapshot() {
303 if m.Role == provider.RoleUser && m.Content == "second prompt" {
304 t.Fatalf("original prompt stayed as an active model turn: %+v", ag.Session().Snapshot())
305 }
306 }
307 }
308
308 lines GO