返回 DeepSeek-Reasonix
guards_test.go
根目录 / internal / agent / guards_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "reflect"
8 "strings"
9 "sync/atomic"
10 "testing"
11 "time"
12 "unicode/utf8"
13
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/tool"
17 _ "reasonix/internal/tool/builtin"
18 )
19
20 // TestTruncateToolOutputUnderCap leaves small payloads alone — the cap should
21 // never rewrite content that already fits.
22 func TestTruncateToolOutputUnderCap(t *testing.T) {
23 in := strings.Repeat("a", maxToolOutputBytes)
24 got, notice := truncateToolOutput(in)
25 if got != in {
26 t.Errorf("payload at exactly the cap was rewritten")
27 }
28 if notice != "" {
29 t.Errorf("at-cap payload should not emit a notice, got %q", notice)
30 }
31 }
32
33 // TestTruncateToolOutputHeadTail keeps head+tail of an oversize payload and
34 // inserts a marker; the notice must report the elided byte count truthfully.
35 func TestTruncateToolOutputHeadTail(t *testing.T) {
36 head := strings.Repeat("H", maxToolOutputBytes)
37 tail := strings.Repeat("T", maxToolOutputBytes)
38 in := head + tail
39 out, notice := truncateToolOutput(in)
40 if !strings.HasPrefix(out, "H") || !strings.HasSuffix(out, "T") {
41 t.Errorf("head/tail not preserved at the edges: %q…%q", out[:20], out[len(out)-20:])
42 }
43 if !strings.Contains(out, "truncated") {
44 t.Errorf("truncation marker missing: %q", out)
45 }
46 if len(out) >= len(in) {
47 t.Errorf("output not shorter than input: in=%d out=%d", len(in), len(out))
48 }
49 if !strings.Contains(notice, "truncated") {
50 t.Errorf("notice missing: %q", notice)
51 }
52 }
53
54 // TestTruncateToolOutputRuneBoundaries puts multibyte runes exactly across the
55 // head and tail cut points; the result must still be valid UTF-8.
56 func TestTruncateToolOutputRuneBoundaries(t *testing.T) {
57 in := strings.Repeat("中", maxToolOutputBytes) // 3 bytes each — guarantees a cut inside a rune
58 out, _ := truncateToolOutput(in)
59 if !utf8.ValidString(out) {
60 t.Errorf("truncated output is not valid UTF-8")
61 }
62 }
63
64 // TestFinishReasonMessage only yields a warning for abnormal terminations.
65 // Normal stops are silent (ok=false) so the per-turn line stays clean.
66 func TestFinishReasonMessage(t *testing.T) {
67 silent := []string{"", "stop", "tool_calls"}
68 for _, r := range silent {
69 if msg, ok := finishReasonMessage(&provider.Usage{FinishReason: r}); ok {
70 t.Errorf("finish_reason=%q should be silent, got %q", r, msg)
71 }
72 }
73 loud := map[string]string{
74 "length": "max output",
75 "client_reasoning_limit": "client reasoning safety limit",
76 "content_filter": "content filter",
77 "repetition_truncation": "repetition",
78 }
79 for reason, fragment := range loud {
80 msg, ok := finishReasonMessage(&provider.Usage{FinishReason: reason})
81 if !ok || !strings.Contains(msg, fragment) {
82 t.Errorf("finish_reason=%q: got (%q, %v), want fragment %q", reason, msg, ok, fragment)
83 }
84 }
85 }
86
87 // TestEmptyFinalNotice keeps the user-facing line short while preserving the
88 // diagnostics that tell empty-answer causes apart in expandable details.
89 func TestEmptyFinalNotice(t *testing.T) {
90 msg := emptyFinalNotice()
91 for _, hidden := range []string{"blocked", "finish=", "reasoning="} {
92 if strings.Contains(msg, hidden) {
93 t.Errorf("notice %q should not expose internal diagnostic %q", msg, hidden)
94 }
95 }
96 detail := emptyFinalNoticeDetail("deepseek-flash", &provider.Usage{FinishReason: "stop"}, 512)
97 for _, want := range []string{"deepseek-flash", "finish=stop", "reasoning=512"} {
98 if !strings.Contains(detail, want) {
99 t.Errorf("notice detail %q missing %q", detail, want)
100 }
101 }
102 if got := emptyFinalNoticeDetail("p", nil, 0); !strings.Contains(got, "finish=unknown") {
103 t.Errorf("nil usage should report finish=unknown, got %q", got)
104 }
105 }
106
107 // --- parallel-dispatch tests ---
108
109 // fakeTool is a minimal Tool stand-in for dispatch tests; ReadOnly is
110 // configurable and Execute sleeps a fixed duration so we can measure
111 // serial vs parallel behaviour by wall-clock.
112 type fakeTool struct {
113 name string
114 readOnly bool
115 delay time.Duration
116 err error
117 calls *int32 // shared counter to assert all dispatched
118 }
119
120 func (f fakeTool) Name() string { return f.name }
121 func (f fakeTool) Description() string { return "" }
122 func (f fakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
123 func (f fakeTool) ReadOnly() bool { return f.readOnly }
124 func (f fakeTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
125 if f.calls != nil {
126 atomic.AddInt32(f.calls, 1)
127 }
128 select {
129 case <-time.After(f.delay):
130 case <-ctx.Done():
131 return "", ctx.Err()
132 }
133 if f.err != nil {
134 return "", f.err
135 }
136 return f.name + " done", nil
137 }
138
139 func TestPartitionToolCallsAllReadOnly(t *testing.T) {
140 reg := tool.NewRegistry()
141 reg.Add(fakeTool{name: "ro1", readOnly: true})
142 reg.Add(fakeTool{name: "ro2", readOnly: true})
143 calls := []provider.ToolCall{{Name: "ro1"}, {Name: "ro2"}}
144 got := partitionToolCalls(reg, calls)
145 want := []toolCallBatch{{start: 0, end: 2, parallel: true}}
146 if !reflect.DeepEqual(got, want) {
147 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
148 }
149 }
150
151 // TestPartitionToolCallsSegmentsAroundWriters verifies a writer only serializes
152 // its own provider-order position; read-only runs on either side stay batchable.
153 func TestPartitionToolCallsSegmentsAroundWriters(t *testing.T) {
154 reg := tool.NewRegistry()
155 reg.Add(fakeTool{name: "ro", readOnly: true})
156 reg.Add(fakeTool{name: "rw", readOnly: false})
157 calls := []provider.ToolCall{{Name: "ro"}, {Name: "rw"}, {Name: "ro"}}
158 got := partitionToolCalls(reg, calls)
159 want := []toolCallBatch{
160 {start: 0, end: 1, parallel: true},
161 {start: 1, end: 2},
162 {start: 2, end: 3, parallel: true},
163 }
164 if !reflect.DeepEqual(got, want) {
165 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
166 }
167 }
168
169 // TestPartitionToolCallsUnknownToolSerial keeps unknown-tool errors
170 // deterministic by forcing unknown calls into single-call serial batches.
171 func TestPartitionToolCallsUnknownToolSerial(t *testing.T) {
172 reg := tool.NewRegistry()
173 reg.Add(fakeTool{name: "ro", readOnly: true})
174 calls := []provider.ToolCall{{Name: "ro"}, {Name: "vanished"}, {Name: "ro"}}
175 got := partitionToolCalls(reg, calls)
176 want := []toolCallBatch{
177 {start: 0, end: 1, parallel: true},
178 {start: 1, end: 2},
179 {start: 2, end: 3, parallel: true},
180 }
181 if !reflect.DeepEqual(got, want) {
182 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
183 }
184 }
185
186 // TestPartitionToolCallsCompleteStepSerial verifies complete_step never joins a
187 // parallel read-only run: it reads the turn's receipts, so the prior reads must
188 // finish (and record) in an earlier batch before it runs in its own serial one.
189 func TestPartitionToolCallsCompleteStepSerial(t *testing.T) {
190 reg := tool.NewRegistry()
191 reg.Add(fakeTool{name: "read_file", readOnly: true})
192 reg.Add(fakeTool{name: "complete_step", readOnly: true})
193
194 calls := []provider.ToolCall{{Name: "read_file"}, {Name: "complete_step"}}
195 got := partitionToolCalls(reg, calls)
196 want := []toolCallBatch{
197 {start: 0, end: 1, parallel: true},
198 {start: 1, end: 2},
199 }
200 if !reflect.DeepEqual(got, want) {
201 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
202 }
203 }
204
205 func TestPartitionToolCallsTodoWriteSerial(t *testing.T) {
206 reg := tool.NewRegistry()
207 reg.Add(fakeTool{name: "read_file", readOnly: true})
208 reg.Add(fakeTool{name: "todo_write", readOnly: true})
209
210 calls := []provider.ToolCall{{Name: "read_file"}, {Name: "todo_write"}, {Name: "read_file"}}
211 got := partitionToolCalls(reg, calls)
212 want := []toolCallBatch{
213 {start: 0, end: 1, parallel: true},
214 {start: 1, end: 2},
215 {start: 2, end: 3, parallel: true},
216 }
217 if !reflect.DeepEqual(got, want) {
218 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
219 }
220 }
221
222 func TestPartitionToolCallsBackgroundCollectorsSerial(t *testing.T) {
223 reg := tool.NewRegistry()
224 reg.Add(fakeTool{name: "read_file", readOnly: true})
225 reg.Add(fakeTool{name: "wait", readOnly: true})
226 reg.Add(fakeTool{name: "bash_output", readOnly: true})
227
228 calls := []provider.ToolCall{{Name: "read_file"}, {Name: "wait"}, {Name: "bash_output"}, {Name: "read_file"}}
229 got := partitionToolCalls(reg, calls)
230 want := []toolCallBatch{
231 {start: 0, end: 1, parallel: true},
232 {start: 1, end: 2},
233 {start: 2, end: 3},
234 {start: 3, end: 4, parallel: true},
235 }
236 if !reflect.DeepEqual(got, want) {
237 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
238 }
239 }
240
241 // TestExecuteBatchParallelReadOnly checks that three 80ms read-only calls
242 // complete in well under 3×80ms — the wall-clock proof of true parallelism.
243 func TestExecuteBatchParallelReadOnly(t *testing.T) {
244 const delay = 80 * time.Millisecond
245 calls := int32(0)
246 reg := tool.NewRegistry()
247 reg.Add(fakeTool{name: "a", readOnly: true, delay: delay, calls: &calls})
248 reg.Add(fakeTool{name: "b", readOnly: true, delay: delay, calls: &calls})
249 reg.Add(fakeTool{name: "c", readOnly: true, delay: delay, calls: &calls})
250
251 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
252
253 start := time.Now()
254 batch := a.executeBatch(context.Background(), []provider.ToolCall{{Name: "a"}, {Name: "b"}, {Name: "c"}})
255 results := batch.results
256 elapsed := time.Since(start)
257
258 if calls != 3 {
259 t.Errorf("dispatched %d calls, want 3", calls)
260 }
261 if len(results) != 3 || results[0] != "a done" || results[1] != "b done" || results[2] != "c done" {
262 t.Errorf("results out of order or wrong: %v", results)
263 }
264 // Allow generous slack for CI; even 2x serial would prove we got parallelism.
265 if elapsed >= 2*delay {
266 t.Errorf("read-only batch took %v (>= %v) — not parallel", elapsed, 2*delay)
267 }
268 }
269
270 // TestExecuteBatchSegmentsAroundWrites ensures a write call only serializes its
271 // own position in the provider-ordered batch: read-only runs before and after it
272 // may still parallelise within their contiguous segments.
273 func TestExecuteBatchSegmentsAroundWrites(t *testing.T) {
274 // A larger per-call delay keeps fixed scheduler jitter on loaded CI a small
275 // fraction of the segment time, so the tight relative bound below stays
276 // reliable instead of being widened toward the serial floor.
277 const delay = 150 * time.Millisecond
278 reg := tool.NewRegistry()
279 reg.Add(fakeTool{name: "ro1", readOnly: true, delay: delay})
280 reg.Add(fakeTool{name: "ro2", readOnly: true, delay: delay})
281 reg.Add(fakeTool{name: "ro3", readOnly: true, delay: delay})
282 reg.Add(fakeTool{name: "ro4", readOnly: true, delay: delay})
283 reg.Add(fakeTool{name: "rw", readOnly: false, delay: delay})
284
285 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
286
287 start := time.Now()
288 batch := a.executeBatch(context.Background(), []provider.ToolCall{
289 {Name: "ro1"},
290 {Name: "ro2"},
291 {Name: "rw"},
292 {Name: "ro3"},
293 {Name: "ro4"},
294 })
295 results := batch.results
296 elapsed := time.Since(start)
297
298 want := []string{"ro1 done", "ro2 done", "rw done", "ro3 done", "ro4 done"}
299 if len(results) != len(want) {
300 t.Fatalf("got %d results, want %d: %v", len(results), len(want), results)
301 }
302 for i := range want {
303 if results[i] != want[i] {
304 t.Fatalf("results out of order or wrong: got %v want %v", results, want)
305 }
306 }
307 // Desired shape is roughly 3*delay: (ro1|ro2), then rw, then (ro3|ro4).
308 // Old all-serial behaviour is roughly 5*delay and should fail this bound.
309 if elapsed >= 4*delay {
310 t.Errorf("mixed batch took %v (>= %v) — read-only segments did not parallelise", elapsed, 4*delay)
311 }
312 if elapsed < 2*delay {
313 t.Errorf("mixed batch took only %v — write call appears to have overlapped a read-only segment", elapsed)
314 }
315 }
316
317 func TestExecuteBatchFeedsReceiptsToCompleteStep(t *testing.T) {
318 completeStep, ok := tool.LookupBuiltin("complete_step")
319 if !ok {
320 t.Fatal("complete_step builtin not registered")
321 }
322 reg := tool.NewRegistry()
323 reg.Add(fakeTool{name: "bash", readOnly: false})
324 reg.Add(completeStep)
325 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
326
327 batch := a.executeBatch(context.Background(), []provider.ToolCall{
328 {Name: "bash", Arguments: `{"command":"go test ./internal/..."}`},
329 {Name: "complete_step", Arguments: `{
330 "step":"Run checks",
331 "result":"checks passed",
332 "evidence":[{"kind":"verification","summary":"tests passed","command":"go test ./internal/..."}]
333 }`},
334 })
335 results := batch.results
336
337 if len(results) != 2 {
338 t.Fatalf("got %d results, want 2", len(results))
339 }
340 if !strings.Contains(results[1], "host-verified 1") {
341 t.Fatalf("complete_step did not see bash receipt: %q", results[1])
342 }
343 }
344
345 func TestExecuteOneFailedReceiptDoesNotVerify(t *testing.T) {
346 reg := tool.NewRegistry()
347 reg.Add(fakeTool{name: "bash", readOnly: false, err: errors.New("boom")})
348 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
349
350 out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: `{"command":"go test ./..."}`})
351 if out.errMsg == "" {
352 t.Fatal("failing fake tool should return an error outcome")
353 }
354 if a.evidence.HasSuccessfulCommand("go test ./...") {
355 t.Fatal("failed bash receipt must not verify")
356 }
357 }
358
359 func TestRunResetsEvidenceLedger(t *testing.T) {
360 reg := tool.NewRegistry()
361 reg.Add(fakeTool{name: "bash", readOnly: false})
362 prov := &mockProvider{name: "p", chunks: []provider.Chunk{{Type: provider.ChunkText, Text: "done"}}}
363 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
364
365 a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: `{"command":"go test ./..."}`})
366 if !a.evidence.HasSuccessfulCommand("go test ./...") {
367 t.Fatal("setup failed to record evidence")
368 }
369
370 if err := a.Run(context.Background(), "next turn"); err != nil {
371 t.Fatalf("Run: %v", err)
372 }
373 if a.evidence.HasSuccessfulCommand("go test ./...") {
374 t.Fatal("new user turn should not inherit previous receipts")
375 }
376 }
377
377 lines GO