返回 DeepSeek-Reasonix
storm_test.go
根目录 / internal / agent / storm_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "testing"
9
10 "reasonix/internal/event"
11 "reasonix/internal/provider"
12 "reasonix/internal/tool"
13 )
14
15 // failTool always errors with the same message regardless of its arguments,
16 // standing in for a call the model keeps re-emitting (e.g. arguments truncated at
17 // the output-token ceiling, which fail to parse the same way every time even as
18 // the model re-words the payload).
19 type failTool struct{ name string }
20
21 func (f failTool) Name() string { return f.name }
22 func (f failTool) Description() string { return "always fails" }
23 func (f failTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
24 func (f failTool) ReadOnly() bool { return true }
25 func (f failTool) Execute(context.Context, json.RawMessage) (string, error) {
26 return "", errors.New("unexpected end of JSON input")
27 }
28
29 // okTool always succeeds — a turn of real progress that breaks a failing run.
30 type okTool struct{ name string }
31
32 func (o okTool) Name() string { return o.name }
33 func (o okTool) Description() string { return "always succeeds" }
34 func (o okTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
35 func (o okTool) ReadOnly() bool { return true }
36 func (o okTool) Execute(context.Context, json.RawMessage) (string, error) { return "ok", nil }
37
38 func noticeRecorder() (event.Sink, *[]string) {
39 var notices []string
40 sink := event.FuncSink(func(e event.Event) {
41 if e.Kind == event.Notice {
42 notices = append(notices, e.Text)
43 }
44 })
45 return sink, &notices
46 }
47
48 // TestStormBreakerEscalatesRepeatedFailure: once the same tool has failed the
49 // same way stormBreakThreshold times in a row, the model-facing result must carry
50 // the loop-guard directive (not just the raw error again), and the user must get
51 // a notice. The arguments DIFFER on every call — mirroring the live failure
52 // mode where a stuck model re-words the payload — to prove detection keys on the
53 // error, not the bytes.
54 func TestStormBreakerEscalatesRepeatedFailure(t *testing.T) {
55 reg := tool.NewRegistry()
56 reg.Add(failTool{name: "write_file"})
57 sink, notices := noticeRecorder()
58 a := New(nil, reg, NewSession(""), Options{}, sink)
59
60 args := []string{`{"content":"Mountains are`, `{"path":"n.txt","content":"Peaks rise`, `{}`}
61 var last string
62 for i := 0; i < stormBreakThreshold; i++ {
63 call := provider.ToolCall{Name: "write_file", Arguments: args[i]}
64 last = executeBatchOutputs(a, context.Background(), []provider.ToolCall{call})[0]
65 }
66
67 if !strings.Contains(last, "[loop guard]") {
68 t.Fatalf("after %d same-error failures the result should carry the loop guard, got: %q", stormBreakThreshold, last)
69 }
70 if !strings.Contains(last, "write_file") {
71 t.Errorf("loop-guard text should name the offending tool, got: %q", last)
72 }
73 if !strings.Contains(last, "unexpected end of JSON input") {
74 t.Errorf("loop-guard result should still preserve the original error, got: %q", last)
75 }
76 if len(*notices) == 0 {
77 t.Errorf("loop guard should emit a notice to the user")
78 }
79 }
80
81 // TestStormBreakerEscalatesRepeatedBlockedPermission covers the readiness
82 // recovery failure mode where the model keeps changing bash commands after the
83 // host returns the same permission denial. Blocked calls used to reset the storm
84 // counter, so this loop could churn approval prompts without ever changing
85 // approach.
86 func TestStormBreakerEscalatesRepeatedBlockedPermission(t *testing.T) {
87 reg := tool.NewRegistry()
88 reg.Add(fakeTool{name: "bash", readOnly: false})
89 sink, notices := noticeRecorder()
90 a := New(nil, reg, NewSession(""), Options{
91 Gate: &stubGate{deny: map[string]bool{"bash": true}},
92 }, sink)
93
94 args := []string{
95 `{"command":"go test ./..."}`,
96 `{"command":"git status --short"}`,
97 `{"command":"ls -la"}`,
98 }
99 var last string
100 for i := 0; i < stormBreakThreshold; i++ {
101 call := provider.ToolCall{Name: "bash", Arguments: args[i]}
102 last = executeBatchOutputs(a, context.Background(), []provider.ToolCall{call})[0]
103 }
104
105 if !strings.Contains(last, "[loop guard]") {
106 t.Fatalf("after %d same permission blocks the result should carry the loop guard, got: %q", stormBreakThreshold, last)
107 }
108 if !strings.Contains(last, "blocked") || !strings.Contains(last, "permission") {
109 t.Fatalf("permission loop guard should preserve blocked context, got: %q", last)
110 }
111 if len(*notices) == 0 {
112 t.Errorf("loop guard should emit a notice to the user")
113 }
114 }
115
116 // TestStormBreakerEscalatesAlternatingBlockedShapes: rotating between two
117 // blocked tools defeats the signature detector (each turn resets the count),
118 // but every turn is still a host refusal with zero progress. The blocked-turn
119 // streak must trip the guard anyway.
120 func TestStormBreakerEscalatesAlternatingBlockedShapes(t *testing.T) {
121 reg := tool.NewRegistry()
122 reg.Add(fakeTool{name: "bash", readOnly: false})
123 reg.Add(fakeTool{name: "web_fetch", readOnly: false})
124 sink, notices := noticeRecorder()
125 a := New(nil, reg, NewSession(""), Options{
126 Gate: &stubGate{deny: map[string]bool{"bash": true, "web_fetch": true}},
127 }, sink)
128
129 calls := []provider.ToolCall{
130 {Name: "bash", Arguments: `{"command":"go test ./..."}`},
131 {Name: "web_fetch", Arguments: `{"url":"https://example.com"}`},
132 {Name: "bash", Arguments: `{"command":"ls"}`},
133 }
134 var last string
135 for _, call := range calls {
136 last = executeBatchOutputs(a, context.Background(), []provider.ToolCall{call})[0]
137 }
138
139 if !strings.Contains(last, "[loop guard]") {
140 t.Fatalf("after %d all-blocked turns the guard should fire despite alternating tools, got: %q", stormBreakThreshold, last)
141 }
142 if !a.loopGuardArmed {
143 t.Fatal("streak guard should arm the final-readiness loop-guard pass")
144 }
145 if len(*notices) == 0 {
146 t.Errorf("streak loop guard should emit a notice to the user")
147 }
148 }
149
150 // TestStormBreakerBlockedStreakResetBySuccess: a turn that makes real progress
151 // proves the model is not stuck, so the blocked-turn streak must start over.
152 func TestStormBreakerBlockedStreakResetBySuccess(t *testing.T) {
153 reg := tool.NewRegistry()
154 reg.Add(fakeTool{name: "bash", readOnly: false})
155 reg.Add(fakeTool{name: "read_file", readOnly: true})
156 a := New(nil, reg, NewSession(""), Options{
157 Gate: &stubGate{deny: map[string]bool{"bash": true}},
158 }, event.Discard)
159
160 calls := []provider.ToolCall{
161 {Name: "bash", Arguments: `{"command":"go test ./..."}`},
162 {Name: "bash", Arguments: `{"command":"ls"}`},
163 {Name: "read_file", Arguments: `{"path":"a.go"}`},
164 {Name: "bash", Arguments: `{"command":"pwd"}`},
165 }
166 var last string
167 for _, call := range calls {
168 last = executeBatchOutputs(a, context.Background(), []provider.ToolCall{call})[0]
169 }
170
171 if strings.Contains(last, "[loop guard]") {
172 t.Fatalf("a successful turn should reset the blocked streak, got: %q", last)
173 }
174 if a.blockedTurnStreak != 1 {
175 t.Fatalf("blockedTurnStreak = %d, want 1 after success reset plus one block", a.blockedTurnStreak)
176 }
177 }
178
179 // TestStormBreakerEscalatesRepeatedBatch: a multi-call batch that fails the same
180 // way every round is just as much a death-spiral as a single call — once the whole
181 // batch repeats stormBreakThreshold times, the guard must fire and name the batch.
182 func TestStormBreakerEscalatesRepeatedBatch(t *testing.T) {
183 reg := tool.NewRegistry()
184 reg.Add(failTool{name: "write_a"})
185 reg.Add(failTool{name: "write_b"})
186 sink, notices := noticeRecorder()
187 a := New(nil, reg, NewSession(""), Options{}, sink)
188
189 batch := []provider.ToolCall{
190 {Name: "write_a", Arguments: `{"content":"x`},
191 {Name: "write_b", Arguments: `{"content":"y`},
192 }
193 var first string
194 for i := 0; i < stormBreakThreshold; i++ {
195 first = executeBatchOutputs(a, context.Background(), batch)[0]
196 }
197
198 if !strings.Contains(first, "[loop guard]") {
199 t.Fatalf("a repeated all-failing batch should trip the guard, got: %q", first)
200 }
201 if !strings.Contains(first, "batch of 2") {
202 t.Errorf("guard should name the repeated batch, got: %q", first)
203 }
204 if len(*notices) == 0 {
205 t.Errorf("loop guard should emit a notice for a repeated batch")
206 }
207 }
208
209 // TestStormBreakerBatchResetsOnPartialSuccess: a batch where even one call
210 // succeeds is progress, not a fixation — the guard must never fire, however many
211 // times the batch repeats.
212 func TestStormBreakerBatchResetsOnPartialSuccess(t *testing.T) {
213 reg := tool.NewRegistry()
214 reg.Add(failTool{name: "write_file"})
215 reg.Add(okTool{name: "read_file"})
216 sink, notices := noticeRecorder()
217 a := New(nil, reg, NewSession(""), Options{}, sink)
218
219 batch := []provider.ToolCall{
220 {Name: "write_file", Arguments: `{"content":"x`},
221 {Name: "read_file", Arguments: `{"path":"x"}`},
222 }
223 var first string
224 for i := 0; i < stormBreakThreshold+2; i++ {
225 first = executeBatchOutputs(a, context.Background(), batch)[0]
226 }
227
228 if strings.Contains(first, "[loop guard]") {
229 t.Fatalf("a batch with a succeeding call should never trip the guard, got: %q", first)
230 }
231 if len(*notices) != 0 {
232 t.Errorf("no notice expected when part of the batch succeeds, got %v", *notices)
233 }
234 }
235
236 // TestStormBreakerSilentBelowThreshold: the first few self-corrections are
237 // healthy — the guard must not fire before the threshold.
238 func TestStormBreakerSilentBelowThreshold(t *testing.T) {
239 reg := tool.NewRegistry()
240 reg.Add(failTool{name: "write_file"})
241 sink, notices := noticeRecorder()
242 a := New(nil, reg, NewSession(""), Options{}, sink)
243
244 call := provider.ToolCall{Name: "write_file", Arguments: `{"content":"x`}
245 var last string
246 for i := 0; i < stormBreakThreshold-1; i++ {
247 last = executeBatchOutputs(a, context.Background(), []provider.ToolCall{call})[0]
248 }
249
250 if strings.Contains(last, "[loop guard]") {
251 t.Fatalf("guard fired after only %d repeats (threshold %d)", stormBreakThreshold-1, stormBreakThreshold)
252 }
253 if len(*notices) != 0 {
254 t.Errorf("no notice expected below threshold, got %v", *notices)
255 }
256 }
257
258 // TestStormBreakerResetsOnSuccess: a run of failures broken by a successful turn
259 // must reset the counter, so the guard does not fire prematurely afterward.
260 func TestStormBreakerResetsOnSuccess(t *testing.T) {
261 reg := tool.NewRegistry()
262 reg.Add(failTool{name: "write_file"})
263 reg.Add(okTool{name: "read_file"})
264 sink, notices := noticeRecorder()
265 a := New(nil, reg, NewSession(""), Options{}, sink)
266
267 fail := provider.ToolCall{Name: "write_file", Arguments: `{"content":"x`}
268 good := provider.ToolCall{Name: "read_file", Arguments: `{"path":"x"}`}
269 ctx := context.Background()
270
271 a.executeBatch(ctx, []provider.ToolCall{fail}) // count 1
272 a.executeBatch(ctx, []provider.ToolCall{fail}) // count 2
273 a.executeBatch(ctx, []provider.ToolCall{good}) // success → reset
274 a.executeBatch(ctx, []provider.ToolCall{fail}) // count 1
275 last := executeBatchOutputs(a, ctx, []provider.ToolCall{fail})[0] // count 2 — still below threshold
276
277 if strings.Contains(last, "[loop guard]") {
278 t.Fatalf("guard should have reset after a successful turn, got: %q", last)
279 }
280 if len(*notices) != 0 {
281 t.Errorf("no notice expected when a success breaks the run, got %v", *notices)
282 }
283 }
284
285 // executeBatchOutputs runs the batch and returns just the model-facing outputs.
286 func executeBatchOutputs(a *Agent, ctx context.Context, calls []provider.ToolCall) []string {
287 batch := a.executeBatch(ctx, calls)
288 outputs := batch.results
289 return outputs
290 }
291
291 lines GO