返回 DeepSeek-Reasonix
stale_anchor_guard_test.go
根目录 / internal / agent / stale_anchor_guard_test.go
1 package agent
2
3 import (
4 "context"
5 "strings"
6 "sync/atomic"
7 "testing"
8
9 "reasonix/internal/event"
10 "reasonix/internal/provider"
11 "reasonix/internal/tool"
12 )
13
14 func TestDeleteRangeRequiresReadAfterSameTurnWrite(t *testing.T) {
15 var deleteCalls int32
16 reg := tool.NewRegistry()
17 reg.Add(fakeTool{name: "delete_range", readOnly: false, calls: &deleteCalls})
18
19 args := `{"path":"src/map.html","start_anchor":"before","end_anchor":"after"}`
20 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
21 {
22 toolCallChunk("c1", "delete_range", args),
23 toolCallChunk("c2", "delete_range", args),
24 {Type: provider.ChunkDone},
25 },
26 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
27 }}
28 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
29
30 if err := a.Run(context.Background(), "edit the map"); err != nil {
31 t.Fatalf("Run: %v", err)
32 }
33 if got := atomic.LoadInt32(&deleteCalls); got != 1 {
34 t.Fatalf("delete_range executed %d times, want only the first call", got)
35 }
36 results := toolResults(a.session, "delete_range")
37 if len(results) != 2 {
38 t.Fatalf("tool results = %d, want 2", len(results))
39 }
40 last := results[len(results)-1]
41 for _, want := range []string{"[fresh read required]", "read_file", "multi_edit"} {
42 if !strings.Contains(last, want) {
43 t.Fatalf("blocked result should mention %q, got %q", want, last)
44 }
45 }
46 }
47
48 func TestEditFileAllowedAfterSameTurnWriteWithoutFreshRead(t *testing.T) {
49 var editCalls int32
50 reg := tool.NewRegistry()
51 reg.Add(fakeTool{name: "edit_file", readOnly: false, calls: &editCalls})
52
53 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
54 {
55 toolCallChunk("c1", "edit_file", `{"path":"src/map.html","old_string":"before","new_string":"after"}`),
56 toolCallChunk("c2", "edit_file", `{"path":"src/map.html","old_string":"other","new_string":"updated"}`),
57 {Type: provider.ChunkDone},
58 },
59 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
60 }}
61 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
62
63 if err := a.Run(context.Background(), "edit two independent regions"); err != nil {
64 t.Fatalf("Run: %v", err)
65 }
66 if got := atomic.LoadInt32(&editCalls); got != 2 {
67 t.Fatalf("edit_file executed %d times, want both optimistic exact-match edits", got)
68 }
69 if last := lastToolResult(a.session, "edit_file"); strings.Contains(last, "[fresh read required]") {
70 t.Fatalf("edit_file should rely on its current-file uniqueness check, got %q", last)
71 }
72 }
73
74 func TestDeleteRangeAllowedAfterFreshRead(t *testing.T) {
75 var deleteCalls int32
76 var readCalls int32
77 reg := tool.NewRegistry()
78 reg.Add(fakeTool{name: "delete_range", readOnly: false, calls: &deleteCalls})
79 reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &readCalls})
80
81 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
82 {
83 toolCallChunk("c1", "delete_range", `{"path":"src/map.html","start_anchor":"before","end_anchor":"after"}`),
84 toolCallChunk("c2", "read_file", `{"path":"src/map.html"}`),
85 toolCallChunk("c3", "delete_range", `{"path":"src/map.html","start_anchor":"current","end_anchor":"final"}`),
86 {Type: provider.ChunkDone},
87 },
88 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
89 }}
90 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
91
92 if err := a.Run(context.Background(), "edit the map with a read between edits"); err != nil {
93 t.Fatalf("Run: %v", err)
94 }
95 if got := atomic.LoadInt32(&readCalls); got != 1 {
96 t.Fatalf("read_file executed %d times, want 1", got)
97 }
98 if got := atomic.LoadInt32(&deleteCalls); got != 2 {
99 t.Fatalf("delete_range executed %d times, want 2 after fresh read", got)
100 }
101 if last := lastToolResult(a.session, "delete_range"); strings.Contains(last, "[fresh read required]") {
102 t.Fatalf("fresh read should allow the second edit, got %q", last)
103 }
104 }
105
106 func TestDeleteRangeStillRequiresReadAfterWindowedRead(t *testing.T) {
107 var deleteCalls int32
108 var readCalls int32
109 reg := tool.NewRegistry()
110 reg.Add(fakeTool{name: "delete_range", readOnly: false, calls: &deleteCalls})
111 reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &readCalls})
112
113 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
114 {
115 toolCallChunk("c1", "delete_range", `{"path":"src/map.html","start_anchor":"before","end_anchor":"after"}`),
116 toolCallChunk("c2", "read_file", `{"path":"src/map.html","offset":400,"limit":20}`),
117 toolCallChunk("c3", "delete_range", `{"path":"src/map.html","start_anchor":"current","end_anchor":"final"}`),
118 {Type: provider.ChunkDone},
119 },
120 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
121 }}
122 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
123
124 if err := a.Run(context.Background(), "edit the map with a narrow read between edits"); err != nil {
125 t.Fatalf("Run: %v", err)
126 }
127 if got := atomic.LoadInt32(&readCalls); got != 1 {
128 t.Fatalf("read_file executed %d times, want 1", got)
129 }
130 if got := atomic.LoadInt32(&deleteCalls); got != 1 {
131 t.Fatalf("delete_range executed %d times, want only the first call", got)
132 }
133 if last := lastToolResult(a.session, "delete_range"); !strings.Contains(last, "[fresh read required]") {
134 t.Fatalf("windowed read should not allow the second edit, got %q", last)
135 }
136 }
137
138 func TestMultiEditAllowedAfterSameTurnWrite(t *testing.T) {
139 var editCalls int32
140 var multiCalls int32
141 reg := tool.NewRegistry()
142 reg.Add(fakeTool{name: "edit_file", readOnly: false, calls: &editCalls})
143 reg.Add(fakeTool{name: "multi_edit", readOnly: false, calls: &multiCalls})
144
145 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
146 {
147 toolCallChunk("c1", "edit_file", `{"path":"src/map.html","old_string":"before","new_string":"after"}`),
148 toolCallChunk("c2", "multi_edit", `{"path":"src/map.html","edits":[{"old_string":"current","new_string":"final"}]}`),
149 {Type: provider.ChunkDone},
150 },
151 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
152 }}
153 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
154
155 if err := a.Run(context.Background(), "edit the map atomically"); err != nil {
156 t.Fatalf("Run: %v", err)
157 }
158 if got := atomic.LoadInt32(&editCalls); got != 1 {
159 t.Fatalf("edit_file executed %d times, want 1", got)
160 }
161 if got := atomic.LoadInt32(&multiCalls); got != 1 {
162 t.Fatalf("multi_edit executed %d times, want 1", got)
163 }
164 }
165
165 lines GO