返回 DeepSeek-Reasonix
readfile_envelope_test.go
根目录 / internal / tool / builtin / readfile_envelope_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "reasonix/internal/tool"
13 )
14
15 func writeEnvelopeFixture(t *testing.T, name string, lines int) (dir, path string) {
16 t.Helper()
17 dir = t.TempDir()
18 path = filepath.Join(dir, name)
19 var b strings.Builder
20 for i := 1; i <= lines; i++ {
21 fmt.Fprintf(&b, "line %d\n", i)
22 }
23 if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
24 t.Fatal(err)
25 }
26 return dir, path
27 }
28
29 func readEnvelope(t *testing.T, r readFile, args string) (tool.ReadResultEnvelope, string, bool) {
30 t.Helper()
31 out, env, err := r.ExecuteRead(context.Background(), json.RawMessage(args))
32 if err != nil {
33 t.Fatalf("Execute(%s): %v", args, err)
34 }
35 return env, out, true
36 }
37
38 func TestReadEnvelopeInspectDefaultsToOneBoundedPage(t *testing.T) {
39 dir, _ := writeEnvelopeFixture(t, "a.go", 5)
40 r := readFile{workDir: dir}
41 env, _, ok := readEnvelope(t, r, `{"path":"a.go"}`)
42 if !ok {
43 t.Fatal("ReadEnvelope must describe an inspect read")
44 }
45 if env.ProtocolVersion != tool.ReadResultProtocolVersion {
46 t.Fatalf("ProtocolVersion = %d, want %d", env.ProtocolVersion, tool.ReadResultProtocolVersion)
47 }
48 if env.Intent != tool.ReadIntentInspect {
49 t.Fatalf("Intent = %q, want %q", env.Intent, tool.ReadIntentInspect)
50 }
51 if env.RequestedRange != nil {
52 t.Fatalf("an inspect read requests no window, got %+v", env.RequestedRange)
53 }
54 if got, want := env.DeliveredRanges, []tool.ReadRange{{Start: 0, End: 5}}; len(got) != 1 || got[0] != want[0] {
55 t.Fatalf("DeliveredRanges = %+v, want %+v", got, want)
56 }
57 if env.HasMore || !env.EOF || env.SourceCut != tool.ReadCutNone {
58 t.Fatalf("small file must read to EOF: %+v", env)
59 }
60 if env.NextCursor != "" {
61 t.Fatalf("no continuation cursor at EOF, got %q", env.NextCursor)
62 }
63 if !filepath.IsAbs(env.Source.CanonicalPath) || env.Source.Snapshot == "" {
64 t.Fatalf("source identity missing: %+v", env.Source)
65 }
66 }
67
68 func TestReadEnvelopeRangeReportsRequestedAndDeliveredWindow(t *testing.T) {
69 dir, _ := writeEnvelopeFixture(t, "a.go", 10)
70 r := readFile{workDir: dir}
71 env, _, ok := readEnvelope(t, r, `{"path":"a.go","offset":2,"limit":3}`)
72 if !ok {
73 t.Fatal("ReadEnvelope must describe an explicit range")
74 }
75 if env.Intent != tool.ReadIntentRange {
76 t.Fatalf("Intent = %q, want %q", env.Intent, tool.ReadIntentRange)
77 }
78 if env.RequestedRange == nil || *env.RequestedRange != (tool.ReadRange{Start: 2, End: 5}) {
79 t.Fatalf("RequestedRange = %+v, want {2 5}", env.RequestedRange)
80 }
81 if got, want := env.DeliveredRanges, []tool.ReadRange{{Start: 2, End: 5}}; len(got) != 1 || got[0] != want[0] {
82 t.Fatalf("DeliveredRanges = %+v, want %+v", got, want)
83 }
84 if !env.HasMore || env.EOF || env.SourceCut != tool.ReadCutPageLimit {
85 t.Fatalf("paged read must report a page cut: %+v", env)
86 }
87 cursor, ok := tool.DecodeReadCursor(env.NextCursor)
88 if !ok || cursor.NextStart != 5 {
89 t.Fatalf("NextCursor = %q (cursor %+v, ok=%v), want next_start 5", env.NextCursor, cursor, ok)
90 }
91 if cursor.Path != env.Source.CanonicalPath || cursor.Snapshot != env.Source.Snapshot || cursor.NextStart != 5 {
92 t.Fatalf("cursor %+v must name this envelope's source and position", cursor)
93 }
94 }
95
96 func TestReadEnvelopePastEOFDeliversNothing(t *testing.T) {
97 dir, _ := writeEnvelopeFixture(t, "a.go", 3)
98 r := readFile{workDir: dir}
99 env, _, ok := readEnvelope(t, r, `{"path":"a.go","offset":99,"limit":10}`)
100 if !ok {
101 t.Fatal("ReadEnvelope must describe a past-EOF read")
102 }
103 if len(env.DeliveredRanges) != 0 || !env.EOF || env.HasMore {
104 t.Fatalf("past-EOF read delivered content: %+v", env)
105 }
106 if env.SourceEnd == nil || *env.SourceEnd != 3 {
107 t.Fatalf("past-EOF read must still report the source end, got %+v", env.SourceEnd)
108 }
109 if env.Source.Snapshot == "" {
110 t.Fatal("an existing file must still carry a source snapshot")
111 }
112 }
113
114 func TestReadEnvelopeEmptyFileIsEOF(t *testing.T) {
115 dir := t.TempDir()
116 if err := os.WriteFile(filepath.Join(dir, "empty.txt"), nil, 0o600); err != nil {
117 t.Fatal(err)
118 }
119 r := readFile{workDir: dir}
120 env, _, ok := readEnvelope(t, r, `{"path":"empty.txt"}`)
121 if !ok || !env.EOF || env.HasMore || len(env.DeliveredRanges) != 0 {
122 t.Fatalf("empty file envelope = %+v (ok=%v)", env, ok)
123 }
124 }
125
126 func TestReadEnvelopeVersionTokenFollowsDeliveredContent(t *testing.T) {
127 dir, path := writeEnvelopeFixture(t, "a.go", 4)
128 r := readFile{workDir: dir}
129 before, _, ok := readEnvelope(t, r, `{"path":"a.go"}`)
130 if !ok {
131 t.Fatal("first read must produce an envelope")
132 }
133 if err := os.WriteFile(path, []byte("line 1\nline 2 changed\nline 3\nline 4\n"), 0o600); err != nil {
134 t.Fatal(err)
135 }
136 after, _, ok := readEnvelope(t, r, `{"path":"a.go"}`)
137 if !ok {
138 t.Fatal("second read must produce an envelope")
139 }
140 if before.Source.Snapshot == after.Source.Snapshot {
141 t.Fatal("an edited line must change the content version token")
142 }
143 if before.DeliveredRanges[0] != after.DeliveredRanges[0] {
144 t.Fatalf("window geometry changed unexpectedly: %+v vs %+v", before.DeliveredRanges, after.DeliveredRanges)
145 }
146 }
147
148 // TestReadEnvelopeSeparatesSourceIdentityFromWindowDigest pins the v2 split: a
149 // line-terminator rewrite moves the source snapshot (the file changed) while the
150 // decoded window digest stays put, so neither can stand in for the other.
151 func TestReadEnvelopeSeparatesSourceIdentityFromWindowDigest(t *testing.T) {
152 dir := t.TempDir()
153 path := filepath.Join(dir, "a.txt")
154 if err := os.WriteFile(path, []byte("alpha\r\nbeta\r\n"), 0o600); err != nil {
155 t.Fatal(err)
156 }
157 r := readFile{workDir: dir}
158 crlf, _, ok := readEnvelope(t, r, `{"path":"a.txt"}`)
159 if !ok {
160 t.Fatal("crlf read must produce an envelope")
161 }
162 if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o600); err != nil {
163 t.Fatal(err)
164 }
165 lf, _, ok := readEnvelope(t, r, `{"path":"a.txt"}`)
166 if !ok {
167 t.Fatal("lf read must produce an envelope")
168 }
169 if crlf.Source.Snapshot == lf.Source.Snapshot {
170 t.Fatalf("a raw byte change must move the source snapshot: %q", crlf.Source.Snapshot)
171 }
172 if crlf.WindowDigest != lf.WindowDigest {
173 t.Fatalf("decoded window digest changed unexpectedly: %q vs %q", crlf.WindowDigest, lf.WindowDigest)
174 }
175 if crlf.DeliveredRanges[0] != lf.DeliveredRanges[0] {
176 t.Fatalf("decoded windows differ: %+v vs %+v", crlf.DeliveredRanges, lf.DeliveredRanges)
177 }
178 }
179
180 func TestReadEnvelopeKeepsUnicodeWindowsIntact(t *testing.T) {
181 dir := t.TempDir()
182 path := filepath.Join(dir, "u.go")
183 body := "设置面板 → 标题\n第二行\nemoji 🙂 结束\n"
184 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
185 t.Fatal(err)
186 }
187 r := readFile{workDir: dir}
188 env, out, ok := readEnvelope(t, r, `{"path":"u.go","offset":1,"limit":2}`)
189 if !ok {
190 t.Fatal("unicode read must produce an envelope")
191 }
192 if got, want := env.DeliveredRanges, []tool.ReadRange{{Start: 1, End: 3}}; len(got) != 1 || got[0] != want[0] {
193 t.Fatalf("DeliveredRanges = %+v, want %+v", got, want)
194 }
195 window, ok := tool.ParseReadWindow(out)
196 if !ok || len(window.Lines) != 2 {
197 t.Fatalf("rendered window = %+v (ok=%v)", window, ok)
198 }
199 if window.Lines[0] != "第二行" {
200 t.Fatalf("first delivered line = %q, want 第二行", window.Lines[0])
201 }
202 if want := tool.WindowDigest(env.Source.CanonicalPath, window); env.WindowDigest != want {
203 t.Fatalf("WindowDigest = %q, want %q", env.WindowDigest, want)
204 }
205 again, _, _ := readEnvelope(t, r, `{"path":"u.go","offset":1,"limit":2}`)
206 if again.Source.Snapshot != env.Source.Snapshot {
207 t.Fatal("identical unicode window must produce a stable version token")
208 }
209 }
210
211 func TestReadIntentDefaultsAndExplicitForms(t *testing.T) {
212 dir, _ := writeEnvelopeFixture(t, "a.go", 10)
213 r := readFile{workDir: dir}
214 cases := []struct {
215 args string
216 wantIntent tool.ReadIntent
217 wantWindow bool
218 }{
219 {`{"path":"a.go"}`, tool.ReadIntentInspect, false},
220 {`{"path":"a.go","offset":1}`, tool.ReadIntentRange, true},
221 {`{"path":"a.go","limit":5}`, tool.ReadIntentRange, true},
222 {`{"path":"a.go","intent":"full"}`, tool.ReadIntentFull, false},
223 {`{"path":"a.go","intent":"inspect","offset":1,"limit":5}`, tool.ReadIntentInspect, true},
224 }
225 for _, tc := range cases {
226 t.Run(tc.args, func(t *testing.T) {
227 env, _, ok := readEnvelope(t, r, tc.args)
228 if !ok {
229 t.Fatalf("ReadEnvelope(%s) reported no envelope", tc.args)
230 }
231 if env.Intent != tc.wantIntent {
232 t.Fatalf("Intent = %q, want %q", env.Intent, tc.wantIntent)
233 }
234 if (env.RequestedRange != nil) != tc.wantWindow {
235 t.Fatalf("RequestedRange = %+v, want present=%v", env.RequestedRange, tc.wantWindow)
236 }
237 })
238 }
239 }
240
241 func TestReadIntentRejectsConflictingParameters(t *testing.T) {
242 dir, _ := writeEnvelopeFixture(t, "a.go", 3)
243 r := readFile{workDir: dir}
244 cases := []struct {
245 name string
246 args string
247 want string
248 }{
249 {"range without a window", `{"path":"a.go","intent":"range"}`, "intent=range requires"},
250 {"unknown intent", `{"path":"a.go","intent":"peek"}`, "intent must be inspect, range, or full"},
251 }
252 for _, tc := range cases {
253 t.Run(tc.name, func(t *testing.T) {
254 _, err := r.Execute(context.Background(), json.RawMessage(tc.args))
255 if err == nil || !strings.Contains(err.Error(), tc.want) {
256 t.Fatalf("Execute(%s) error = %v, want one containing %q", tc.args, err, tc.want)
257 }
258 if _, ok := r.ReadEnvelope(context.Background(), json.RawMessage(tc.args), " 1→a\n"); ok {
259 t.Fatalf("ReadEnvelope(%s) must not describe a rejected call", tc.args)
260 }
261 })
262 }
263 }
264
265 func TestLegacyFullIntentIsOneBoundedWindow(t *testing.T) {
266 dir, _ := writeEnvelopeFixture(t, "a.go", 30)
267 r := readFile{workDir: dir}
268 out, err := r.Execute(context.Background(), json.RawMessage(`{"path":"a.go","intent":"full","offset":10,"limit":2}`))
269 if err != nil {
270 t.Fatal(err)
271 }
272 window, ok := tool.ParseReadWindow(out)
273 if !ok || window.StartLine != 11 || len(window.Lines) != 2 {
274 t.Fatalf("bounded legacy full window = %+v, ok=%v", window, ok)
275 }
276 }
277
278 func TestReadEnvelopeRejectsCallsWithoutAPath(t *testing.T) {
279 r := readFile{workDir: t.TempDir()}
280 if _, ok := r.ReadEnvelope(context.Background(), json.RawMessage(`{"offset":1}`), " 1→a\n"); ok {
281 t.Fatal("a call without a path has no envelope")
282 }
283 if _, ok := r.ReadEnvelope(context.Background(), json.RawMessage(`not json`), " 1→a\n"); ok {
284 t.Fatal("malformed args have no envelope")
285 }
286 }
287
288 func TestReadEnvelopeNeverAppearsInProviderText(t *testing.T) {
289 dir, _ := writeEnvelopeFixture(t, "a.go", 10)
290 r := readFile{workDir: dir}
291 _, out, _ := readEnvelope(t, r, `{"path":"a.go","offset":2,"limit":3}`)
292 for _, leak := range []string{"protocol_version", "version_token", "delivered_ranges", "next_cursor", "read_id"} {
293 if strings.Contains(out, leak) {
294 t.Fatalf("host-only envelope field %q leaked into reader output:\n%s", leak, out)
295 }
296 }
297 }
298
299 func TestReadEnvelopeNamesTheServingStore(t *testing.T) {
300 dir, _ := writeEnvelopeFixture(t, "a.go", 10)
301 r := readFile{workDir: dir}
302 first, _, ok := readEnvelope(t, r, `{"path":"a.go","offset":0,"limit":4}`)
303 if !ok {
304 t.Fatal("first page must produce an envelope")
305 }
306 if first.Source.Kind != tool.ReadSourceDisk || first.Source.Identity == "" || first.Source.Snapshot == "" {
307 t.Fatalf("source identity missing: %+v", first.Source)
308 }
309 second, _, ok := readEnvelope(t, r, `{"path":"a.go","offset":4,"limit":4}`)
310 if !ok {
311 t.Fatal("second page must produce an envelope")
312 }
313 if second.Source.Snapshot != first.Source.Snapshot {
314 t.Fatalf("one read's snapshot must not move between pages: %q vs %q", first.Source.Snapshot, second.Source.Snapshot)
315 }
316 if second.WindowDigest == first.WindowDigest {
317 t.Fatal("different windows must have different digests")
318 }
319 }
320
321 func TestReadEnvelopeSourceEndIsTrustworthy(t *testing.T) {
322 dir, _ := writeEnvelopeFixture(t, "a.go", 3)
323 r := readFile{workDir: dir}
324 complete, _, ok := readEnvelope(t, r, `{"path":"a.go"}`)
325 if !ok || complete.SourceEnd == nil || *complete.SourceEnd != 3 {
326 t.Fatalf("a complete read must report the source end: %+v", complete.SourceEnd)
327 }
328 pastEOF, _, ok := readEnvelope(t, r, `{"path":"a.go","offset":99,"limit":10}`)
329 if !ok || pastEOF.SourceEnd == nil || *pastEOF.SourceEnd != 3 {
330 t.Fatalf("a past-EOF read must report the source end: %+v", pastEOF.SourceEnd)
331 }
332 empty := t.TempDir()
333 if err := os.WriteFile(filepath.Join(empty, "empty.txt"), nil, 0o600); err != nil {
334 t.Fatal(err)
335 }
336 er := readFile{workDir: empty}
337 env, _, ok := readEnvelope(t, er, `{"path":"empty.txt"}`)
338 if !ok || env.SourceEnd == nil || *env.SourceEnd != 0 {
339 t.Fatalf("an empty file must report a zero source end: %+v", env.SourceEnd)
340 }
341 }
342
342 lines GO