返回 DeepSeek-Reasonix
effect_read_file_test.go
根目录 / internal / boot / effect_read_file_test.go
1 package boot
2
3 import (
4 "context"
5 "fmt"
6 "os/exec"
7 "strings"
8 "sync"
9 "testing"
10
11 "reasonix/internal/control"
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 )
15
16 // readThenMutateProvider reads a large file, then runs git commit, then lists
17 // capabilities and finishes. Truncated/windowed reads must not freeze commits
18 // or finals through the real Build stack.
19 type readThenMutateProvider struct {
20 mu sync.Mutex
21 reqs []provider.Request
22 round int
23 }
24
25 func (p *readThenMutateProvider) Name() string { return "boot-read-then-mutate" }
26
27 func (p *readThenMutateProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
28 p.mu.Lock()
29 p.reqs = append(p.reqs, req)
30 p.round++
31 round := p.round
32 p.mu.Unlock()
33 switch round {
34 case 1:
35 return streamChunks(
36 provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
37 ID: "read-1", Name: "read_file", Arguments: `{"path":"big.txt"}`,
38 }},
39 provider.Chunk{Type: provider.ChunkDone},
40 ), nil
41 case 2:
42 return streamChunks(
43 provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
44 ID: "commit-1", Name: "bash", Arguments: `{"command":"git commit -m test-commit"}`,
45 }},
46 provider.Chunk{Type: provider.ChunkDone},
47 ), nil
48 case 3:
49 return streamChunks(
50 provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
51 ID: "list-1", Name: "use_capability", Arguments: `{"action":"list"}`,
52 }},
53 provider.Chunk{Type: provider.ChunkDone},
54 ), nil
55 default:
56 return streamChunks(
57 provider.Chunk{Type: provider.ChunkText, Text: "committed after the partial read"},
58 provider.Chunk{Type: provider.ChunkDone},
59 ), nil
60 }
61 }
62
63 func (p *readThenMutateProvider) requests() []provider.Request {
64 p.mu.Lock()
65 defer p.mu.Unlock()
66 out := make([]provider.Request, len(p.reqs))
67 copy(out, p.reqs)
68 return out
69 }
70
71 func TestEffectTruncatedReadDoesNotBlockCommitOrFinalThroughRealBuild(t *testing.T) {
72 isolateConfigHome(t)
73 dir := robustTempDir(t)
74 t.Chdir(dir)
75 for _, args := range [][]string{
76 {"init"},
77 {"config", "user.email", "test@example.com"},
78 {"config", "user.name", "test"},
79 } {
80 cmd := exec.Command("git", args...)
81 cmd.Dir = dir
82 if out, err := cmd.CombinedOutput(); err != nil {
83 t.Fatalf("git %v: %v\n%s", args, err, out)
84 }
85 }
86 writeFile(t, dir, "tracked.txt", "tracked\n")
87 cmd := exec.Command("git", "add", "tracked.txt")
88 cmd.Dir = dir
89 if out, err := cmd.CombinedOutput(); err != nil {
90 t.Fatalf("git add: %v\n%s", err, out)
91 }
92
93 var body strings.Builder
94 for i := 1; i <= 4500; i++ {
95 fmt.Fprintf(&body, "line-%04d-content-for-pagination-test\n", i)
96 }
97 writeFile(t, dir, "big.txt", body.String())
98
99 rec := &readThenMutateProvider{}
100 provider.Register("boot-read-then-mutate", func(provider.Config) (provider.Provider, error) {
101 return rec, nil
102 })
103 // This case proves the read-evidence gate releases a later commit, so git must
104 // really run. An enforced sandbox fails closed wherever the host lacks a backend
105 // (coverage runners), which is the sandbox's own contract, not this one.
106 writeUserConfig(t, "[sandbox]\nbash = \"off\"\n")
107 writeFile(t, dir, "reasonix.toml", `
108 default_model = "test-model"
109
110 [agent]
111 system_prompt = "BASE"
112
113 [environment]
114 enabled = false
115
116 [[providers]]
117 name = "test-model"
118 kind = "boot-read-then-mutate"
119 model = "x"
120 `)
121 approveWorkspace(t, dir)
122
123 ctrl, err := Build(context.Background(), Options{
124 Sink: event.Discard,
125 PermissionAllow: []string{"Bash(git *)"},
126 })
127 if err != nil {
128 t.Fatalf("Build: %v", err)
129 }
130 defer ctrl.Close()
131 ctrl.ApplyHeadlessApprovalMode(control.ToolApprovalDangerFullAccess)
132
133 if err := ctrl.Run(context.Background(), "read big.txt then commit"); err != nil {
134 t.Fatalf("Run: %v", err)
135 }
136
137 reqs := rec.requests()
138 if len(reqs) < 4 {
139 t.Fatalf("provider rounds=%d, want at least read + commit + list + final", len(reqs))
140 }
141
142 readResult := lastToolContentByID(reqs, "read-1")
143 if readResult == "" {
144 t.Fatal("read_file result never reached a later provider request")
145 }
146 if strings.Contains(readResult, "do not answer, modify state") {
147 t.Fatalf("read_file result still forces completion:\n%.400s", readResult)
148 }
149 if !strings.Contains(readResult, "PARTIAL view") && !strings.Contains(readResult, "next_offset=") {
150 t.Fatalf("read_file result missing pagination hint:\n%.400s", readResult)
151 }
152
153 commitResult := lastToolContentByID(reqs, "commit-1")
154 if commitResult == "" {
155 t.Fatal("git commit result never reached a later provider request")
156 }
157 if strings.Contains(commitResult, "unread content retained") ||
158 strings.Contains(commitResult, "restricted search/read mode") ||
159 strings.Contains(commitResult, "cannot declare which files it changes") {
160 t.Fatalf("git commit was blocked by leftover read gate:\n%.400s", commitResult)
161 }
162
163 verify := exec.Command("git", "log", "-1", "--format=%s")
164 verify.Dir = dir
165 if out, err := verify.CombinedOutput(); err != nil || strings.TrimSpace(string(out)) != "test-commit" {
166 t.Fatalf("commit did not land: %s %v", out, err)
167 }
168
169 listResult := lastToolContentByID(reqs, "list-1")
170 if listResult == "" {
171 t.Fatal("use_capability list result never reached a later provider request")
172 }
173 if !strings.Contains(listResult, "session:tool_result") {
174 t.Fatalf("capability list dropped session:tool_result:\n%.400s", listResult)
175 }
176 }
177
178 func lastToolContentByID(reqs []provider.Request, callID string) string {
179 var found string
180 for _, req := range reqs {
181 for _, msg := range req.Messages {
182 if msg.Role == provider.RoleTool && msg.ToolCallID == callID {
183 found = msg.Content
184 }
185 }
186 }
187 return found
188 }
189
189 lines GO