返回 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 writeFile(t, dir, "reasonix.toml", `
104 default_model = "test-model"
105
106 [agent]
107 system_prompt = "BASE"
108
109 [environment]
110 enabled = false
111
112 # This case proves the read-evidence gate releases a later commit, so git must
113 # really run. An enforced sandbox fails closed wherever the host lacks a backend
114 # (coverage runners), which is the sandbox's own contract, not this one.
115 [sandbox]
116 bash = "off"
117
118 [[providers]]
119 name = "test-model"
120 kind = "boot-read-then-mutate"
121 model = "x"
122 `)
123
124 ctrl, err := Build(context.Background(), Options{
125 Sink: event.Discard,
126 PermissionAllow: []string{"Bash(git *)"},
127 })
128 if err != nil {
129 t.Fatalf("Build: %v", err)
130 }
131 defer ctrl.Close()
132 ctrl.ApplyHeadlessApprovalMode(control.ToolApprovalDangerFullAccess)
133
134 if err := ctrl.Run(context.Background(), "read big.txt then commit"); err != nil {
135 t.Fatalf("Run: %v", err)
136 }
137
138 reqs := rec.requests()
139 if len(reqs) < 4 {
140 t.Fatalf("provider rounds=%d, want at least read + commit + list + final", len(reqs))
141 }
142
143 readResult := lastToolContentByID(reqs, "read-1")
144 if readResult == "" {
145 t.Fatal("read_file result never reached a later provider request")
146 }
147 if strings.Contains(readResult, "do not answer, modify state") {
148 t.Fatalf("read_file result still forces completion:\n%.400s", readResult)
149 }
150 if !strings.Contains(readResult, "PARTIAL view") && !strings.Contains(readResult, "next_offset=") {
151 t.Fatalf("read_file result missing pagination hint:\n%.400s", readResult)
152 }
153
154 commitResult := lastToolContentByID(reqs, "commit-1")
155 if commitResult == "" {
156 t.Fatal("git commit result never reached a later provider request")
157 }
158 if strings.Contains(commitResult, "unread content retained") ||
159 strings.Contains(commitResult, "restricted search/read mode") ||
160 strings.Contains(commitResult, "cannot declare which files it changes") {
161 t.Fatalf("git commit was blocked by leftover read gate:\n%.400s", commitResult)
162 }
163
164 verify := exec.Command("git", "log", "-1", "--format=%s")
165 verify.Dir = dir
166 if out, err := verify.CombinedOutput(); err != nil || strings.TrimSpace(string(out)) != "test-commit" {
167 t.Fatalf("commit did not land: %s %v", out, err)
168 }
169
170 listResult := lastToolContentByID(reqs, "list-1")
171 if listResult == "" {
172 t.Fatal("use_capability list result never reached a later provider request")
173 }
174 if !strings.Contains(listResult, "session:tool_result") {
175 t.Fatalf("capability list dropped session:tool_result:\n%.400s", listResult)
176 }
177 }
178
179 func lastToolContentByID(reqs []provider.Request, callID string) string {
180 var found string
181 for _, req := range reqs {
182 for _, msg := range req.Messages {
183 if msg.Role == provider.RoleTool && msg.ToolCallID == callID {
184 found = msg.Content
185 }
186 }
187 }
188 return found
189 }
190
190 lines GO