返回 DeepSeek-Reasonix
file_observation_test.go
根目录 / internal / tool / builtin / file_observation_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "reasonix/internal/fileops"
13 "reasonix/internal/tool"
14 )
15
16 func observedContext() context.Context {
17 return fileops.WithStore(context.Background(), fileops.NewStore())
18 }
19
20 func TestWindowReadAuthorizesConsecutiveEdits(t *testing.T) {
21 dir := t.TempDir()
22 path := filepath.Join(dir, "中文.txt")
23 if err := os.WriteFile(path, []byte("alpha\r\nbeta\r\ngamma\r\n"), 0o600); err != nil {
24 t.Fatal(err)
25 }
26 ctx := observedContext()
27 r := readFile{workDir: dir}
28 if _, _, err := r.ExecuteRead(ctx, json.RawMessage(`{"path":"中文.txt","limit":1}`)); err != nil {
29 t.Fatal(err)
30 }
31 e := editFile{workDir: dir}
32 if _, err := e.Execute(ctx, json.RawMessage(`{"path":"中文.txt","old_string":"beta","new_string":"BETA"}`)); err != nil {
33 t.Fatal(err)
34 }
35 if _, err := e.Execute(ctx, json.RawMessage(`{"path":"中文.txt","old_string":"gamma","new_string":"GAMMA"}`)); err != nil {
36 t.Fatalf("successful write did not refresh observation: %v", err)
37 }
38 }
39
40 func TestUnobservedOverwriteRejectedButCreationAllowed(t *testing.T) {
41 dir := t.TempDir()
42 path := filepath.Join(dir, "existing")
43 if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
44 t.Fatal(err)
45 }
46 ctx := observedContext()
47 w := writeFile{workDir: dir}
48 _, err := w.Execute(ctx, json.RawMessage(`{"path":"existing","content":"new"}`))
49 var opErr *tool.OperationError
50 if !errors.As(err, &opErr) || opErr.Diagnostic.Code != tool.FSNotObserved {
51 t.Fatalf("unobserved overwrite error = %v", err)
52 }
53 if _, err := w.Execute(ctx, json.RawMessage(`{"path":"created","content":"new"}`)); err != nil {
54 t.Fatalf("unobserved create failed: %v", err)
55 }
56 }
57
58 func TestExternalChangeMakesObservationStale(t *testing.T) {
59 dir := t.TempDir()
60 path := filepath.Join(dir, "file")
61 if err := os.WriteFile(path, []byte("one\n"), 0o600); err != nil {
62 t.Fatal(err)
63 }
64 ctx := observedContext()
65 r := readFile{workDir: dir}
66 if _, _, err := r.ExecuteRead(ctx, json.RawMessage(`{"path":"file","limit":1}`)); err != nil {
67 t.Fatal(err)
68 }
69 stat, _ := os.Stat(path)
70 if err := os.WriteFile(path, []byte("two\n"), 0o600); err != nil {
71 t.Fatal(err)
72 }
73 if err := os.Chtimes(path, stat.ModTime(), stat.ModTime()); err != nil {
74 t.Fatal(err)
75 }
76 e := editFile{workDir: dir}
77 _, err := e.Execute(ctx, json.RawMessage(`{"path":"file","old_string":"two","new_string":"THREE"}`))
78 var opErr *tool.OperationError
79 if !errors.As(err, &opErr) || opErr.Diagnostic.Code != tool.FSStaleVersion {
80 t.Fatalf("stale edit error = %v", err)
81 }
82 if _, _, err := r.ExecuteRead(ctx, json.RawMessage(`{"path":"file","limit":1}`)); err != nil {
83 t.Fatal(err)
84 }
85 if _, err := e.Execute(ctx, json.RawMessage(`{"path":"file","old_string":"two","new_string":"THREE"}`)); err != nil {
86 t.Fatalf("reread did not recover: %v", err)
87 }
88 }
89
90 func TestLargeWindowReadDoesNotCaptureWholeFile(t *testing.T) {
91 dir := t.TempDir()
92 path := filepath.Join(dir, "large.txt")
93 if err := os.WriteFile(path, []byte(strings.Repeat("a bounded local line\n", 20000)), 0o600); err != nil {
94 t.Fatal(err)
95 }
96 ctx := observedContext()
97 r := readFile{workDir: dir}
98 output, env, err := r.ExecuteRead(ctx, json.RawMessage(`{"path":"large.txt","offset":100,"limit":2}`))
99 if err != nil {
100 t.Fatal(err)
101 }
102 if env.Source.Snapshot == "" || len(output) > 300 {
103 t.Fatalf("window/version result unexpected: output=%d snapshot=%q", len(output), env.Source.Snapshot)
104 }
105 }
106
107 func TestConcurrentCreateNeverOverwritesWinner(t *testing.T) {
108 dir := t.TempDir()
109 ctxA, ctxB := observedContext(), observedContext()
110 w := writeFile{workDir: dir}
111 start := make(chan struct{})
112 results := make(chan error, 2)
113 for _, item := range []struct {
114 ctx context.Context
115 content string
116 }{{ctxA, "a"}, {ctxB, "b"}} {
117 go func(ctx context.Context, content string) {
118 <-start
119 _, err := w.Execute(ctx, json.RawMessage(`{"path":"new","content":"`+content+`"}`))
120 results <- err
121 }(item.ctx, item.content)
122 }
123 close(start)
124 err1, err2 := <-results, <-results
125 if (err1 == nil) == (err2 == nil) {
126 t.Fatalf("want one winner and one rejection: %v / %v", err1, err2)
127 }
128 got, err := os.ReadFile(filepath.Join(dir, "new"))
129 if err != nil || (string(got) != "a" && string(got) != "b") {
130 t.Fatalf("winner content = %q, %v", got, err)
131 }
132 }
133
134 func TestConcurrentEditsFromSameVersionDoNotLoseUpdate(t *testing.T) {
135 dir := t.TempDir()
136 path := filepath.Join(dir, "shared.txt")
137 if err := os.WriteFile(path, []byte("base\n"), 0o600); err != nil {
138 t.Fatal(err)
139 }
140 ctxA, ctxB := observedContext(), observedContext()
141 r := readFile{workDir: dir}
142 for _, ctx := range []context.Context{ctxA, ctxB} {
143 if _, _, err := r.ExecuteRead(ctx, json.RawMessage(`{"path":"shared.txt","limit":1}`)); err != nil {
144 t.Fatal(err)
145 }
146 }
147 e := editFile{workDir: dir}
148 start := make(chan struct{})
149 results := make(chan error, 2)
150 for _, item := range []struct {
151 ctx context.Context
152 to string
153 }{{ctxA, "agent-a"}, {ctxB, "agent-b"}} {
154 go func(ctx context.Context, to string) {
155 <-start
156 _, err := e.Execute(ctx, json.RawMessage(`{"path":"shared.txt","old_string":"base","new_string":"`+to+`"}`))
157 results <- err
158 }(item.ctx, item.to)
159 }
160 close(start)
161 err1, err2 := <-results, <-results
162 if (err1 == nil) == (err2 == nil) {
163 t.Fatalf("want one committed edit and one stale edit: %v / %v", err1, err2)
164 }
165 loser := err1
166 if loser == nil {
167 loser = err2
168 }
169 var opErr *tool.OperationError
170 if !errors.As(loser, &opErr) || opErr.Diagnostic.Code != tool.FSStaleVersion {
171 t.Fatalf("loser error = %v", loser)
172 }
173 got, err := os.ReadFile(path)
174 if err != nil || (string(got) != "agent-a\n" && string(got) != "agent-b\n") {
175 t.Fatalf("final content=%q err=%v", got, err)
176 }
177 }
178
178 lines GO