返回 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"
10 "sync/atomic"
11 "testing"
12 "time"
13 "unicode/utf8"
14
15 "reasonix/internal/event"
16 "reasonix/internal/evidence"
17 "reasonix/internal/provider"
18 "reasonix/internal/tool"
19 _ "reasonix/internal/tool/builtin"
20 )
21
22 // TestTruncateToolOutputUnderCap leaves small payloads alone — the cap should
23 // never rewrite content that already fits.
24 func TestTruncateToolOutputUnderCap(t *testing.T) {
25 in := strings.Repeat("a", maxToolOutputBytes)
26 got, notice := truncateToolOutput(in)
27 if got != in {
28 t.Errorf("payload at exactly the cap was rewritten")
29 }
30 if notice != "" {
31 t.Errorf("at-cap payload should not emit a notice, got %q", notice)
32 }
33 }
34
35 // TestTruncateToolOutputHeadTail keeps head+tail of an oversize payload and
36 // inserts a marker; the notice must report the elided byte count truthfully.
37 func TestTruncateToolOutputHeadTail(t *testing.T) {
38 head := strings.Repeat("H", maxToolOutputBytes)
39 tail := strings.Repeat("T", maxToolOutputBytes)
40 in := head + tail
41 out, notice := truncateToolOutput(in)
42 if !strings.HasPrefix(out, "H") || !strings.HasSuffix(out, "T") {
43 t.Errorf("head/tail not preserved at the edges: %q…%q", out[:20], out[len(out)-20:])
44 }
45 if !strings.Contains(out, "truncated") {
46 t.Errorf("truncation marker missing: %q", out)
47 }
48 if len(out) >= len(in) {
49 t.Errorf("output not shorter than input: in=%d out=%d", len(in), len(out))
50 }
51 if !strings.Contains(notice, "truncated") {
52 t.Errorf("notice missing: %q", notice)
53 }
54 }
55
56 // TestTruncateToolOutputRuneBoundaries puts multibyte runes exactly across the
57 // head and tail cut points; the result must still be valid UTF-8.
58 func TestTruncateToolOutputRuneBoundaries(t *testing.T) {
59 in := strings.Repeat("中", maxToolOutputBytes) // 3 bytes each — guarantees a cut inside a rune
60 out, _ := truncateToolOutput(in)
61 if !utf8.ValidString(out) {
62 t.Errorf("truncated output is not valid UTF-8")
63 }
64 }
65
66 // TestFinishReasonMessage only yields a warning for abnormal terminations.
67 // Normal stops are silent (ok=false) so the per-turn line stays clean.
68 func TestFinishReasonMessage(t *testing.T) {
69 silent := []string{"", "stop", "tool_calls"}
70 for _, r := range silent {
71 if msg, ok := finishReasonMessage(&provider.Usage{FinishReason: r}); ok {
72 t.Errorf("finish_reason=%q should be silent, got %q", r, msg)
73 }
74 }
75 loud := map[string]string{
76 "length": "max output",
77 "content_filter": "content filter",
78 "repetition_truncation": "repetition",
79 }
80 for reason, fragment := range loud {
81 msg, ok := finishReasonMessage(&provider.Usage{FinishReason: reason})
82 if !ok || !strings.Contains(msg, fragment) {
83 t.Errorf("finish_reason=%q: got (%q, %v), want fragment %q", reason, msg, ok, fragment)
84 }
85 }
86 }
87
88 // TestEmptyFinalNotice keeps the user-facing line short while preserving the
89 // diagnostics that tell empty-answer causes apart in expandable details.
90 func TestEmptyFinalNotice(t *testing.T) {
91 msg := emptyFinalNotice()
92 for _, hidden := range []string{"blocked", "finish=", "reasoning="} {
93 if strings.Contains(msg, hidden) {
94 t.Errorf("notice %q should not expose internal diagnostic %q", msg, hidden)
95 }
96 }
97 detail := emptyFinalNoticeDetail("deepseek-flash", &provider.Usage{FinishReason: "stop"}, 512)
98 for _, want := range []string{"deepseek-flash", "finish=stop", "reasoning=512"} {
99 if !strings.Contains(detail, want) {
100 t.Errorf("notice detail %q missing %q", detail, want)
101 }
102 }
103 if got := emptyFinalNoticeDetail("p", nil, 0); !strings.Contains(got, "finish=unknown") {
104 t.Errorf("nil usage should report finish=unknown, got %q", got)
105 }
106 }
107
108 // parallel-dispatch tests
109
110 // fakeTool is a minimal Tool stand-in for dispatch tests; ReadOnly is
111 // configurable and Execute sleeps a fixed duration so we can measure
112 // serial vs parallel behaviour by wall-clock.
113 type fakeTool struct {
114 name string
115 readOnly bool
116 delay time.Duration
117 err error
118 calls *int32 // shared counter to assert all dispatched
119 }
120
121 type blockingTool struct {
122 name string
123 started chan struct{}
124 release chan struct{}
125 }
126
127 func (b blockingTool) Name() string { return b.name }
128 func (b blockingTool) Description() string { return "" }
129 func (b blockingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
130 func (b blockingTool) ReadOnly() bool { return true }
131 func (b blockingTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
132 close(b.started)
133 select {
134 case <-b.release:
135 return b.name + " done", nil
136 case <-ctx.Done():
137 return "", ctx.Err()
138 }
139 }
140
141 type workspaceSignalSink struct {
142 mu sync.Mutex
143 events []event.Event
144 mutations chan event.WorkspaceMutation
145 }
146
147 type failingToolDispatchSink struct{ err error }
148
149 func (s failingToolDispatchSink) Emit(event.Event) {}
150 func (s failingToolDispatchSink) EmitChecked(e event.Event) error {
151 if e.Kind == event.ToolDispatch {
152 return s.err
153 }
154 return nil
155 }
156
157 func newWorkspaceSignalSink() *workspaceSignalSink {
158 return &workspaceSignalSink{mutations: make(chan event.WorkspaceMutation, 8)}
159 }
160
161 func (s *workspaceSignalSink) Emit(e event.Event) {
162 s.mu.Lock()
163 s.events = append(s.events, e)
164 s.mu.Unlock()
165 }
166
167 func (s *workspaceSignalSink) RecordWorkspaceMutation(m event.WorkspaceMutation) {
168 s.mutations <- m
169 }
170
171 func (s *workspaceSignalSink) kinds(kind event.Kind) []event.Event {
172 s.mu.Lock()
173 defer s.mu.Unlock()
174 var out []event.Event
175 for _, e := range s.events {
176 if e.Kind == kind {
177 out = append(out, e)
178 }
179 }
180 return out
181 }
182
183 func (f fakeTool) Name() string { return f.name }
184 func (f fakeTool) Description() string { return "" }
185 func (f fakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
186 func (f fakeTool) ReadOnly() bool { return f.readOnly }
187 func (f fakeTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
188 if f.calls != nil {
189 atomic.AddInt32(f.calls, 1)
190 }
191 select {
192 case <-time.After(f.delay):
193 case <-ctx.Done():
194 return "", ctx.Err()
195 }
196 if f.err != nil {
197 return "", f.err
198 }
199 return f.name + " done", nil
200 }
201
202 func TestPartitionToolCallsAllReadOnly(t *testing.T) {
203 reg := tool.NewRegistry()
204 reg.Add(fakeTool{name: "ro1", readOnly: true})
205 reg.Add(fakeTool{name: "ro2", readOnly: true})
206 calls := []provider.ToolCall{{Name: "ro1"}, {Name: "ro2"}}
207 got := partitionToolCalls(reg, calls)
208 want := []toolCallBatch{{start: 0, end: 2, parallel: true}}
209 if !reflect.DeepEqual(got, want) {
210 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
211 }
212 }
213
214 // TestPartitionToolCallsSegmentsAroundWriters verifies a writer only serializes
215 // its own provider-order position; read-only runs on either side stay batchable.
216 func TestPartitionToolCallsSegmentsAroundWriters(t *testing.T) {
217 reg := tool.NewRegistry()
218 reg.Add(fakeTool{name: "ro", readOnly: true})
219 reg.Add(fakeTool{name: "rw", readOnly: false})
220 calls := []provider.ToolCall{{Name: "ro"}, {Name: "rw"}, {Name: "ro"}}
221 got := partitionToolCalls(reg, calls)
222 want := []toolCallBatch{
223 {start: 0, end: 1, parallel: true},
224 {start: 1, end: 2},
225 {start: 2, end: 3, parallel: true},
226 }
227 if !reflect.DeepEqual(got, want) {
228 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
229 }
230 }
231
232 // TestPartitionToolCallsUnknownToolSerial keeps unknown-tool errors
233 // deterministic by forcing unknown calls into single-call serial batches.
234 func TestPartitionToolCallsUnknownToolSerial(t *testing.T) {
235 reg := tool.NewRegistry()
236 reg.Add(fakeTool{name: "ro", readOnly: true})
237 calls := []provider.ToolCall{{Name: "ro"}, {Name: "vanished"}, {Name: "ro"}}
238 got := partitionToolCalls(reg, calls)
239 want := []toolCallBatch{
240 {start: 0, end: 1, parallel: true},
241 {start: 1, end: 2},
242 {start: 2, end: 3, parallel: true},
243 }
244 if !reflect.DeepEqual(got, want) {
245 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
246 }
247 }
248
249 // A retired tool accidentally present in an embedding registry carries no
250 // active receipt semantics and does not change read-only partitioning.
251 func TestPartitionToolCallsRetiredToolHasNoBarrier(t *testing.T) {
252 reg := tool.NewRegistry()
253 reg.Add(fakeTool{name: "read_file", readOnly: true})
254 reg.Add(fakeTool{name: "complete_step", readOnly: true})
255
256 calls := []provider.ToolCall{{Name: "read_file"}, {Name: "complete_step"}}
257 got := partitionToolCalls(reg, calls)
258 want := []toolCallBatch{{start: 0, end: 2, parallel: true}}
259 if !reflect.DeepEqual(got, want) {
260 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
261 }
262 }
263
264 func TestPartitionToolCallsCompressSerial(t *testing.T) {
265 reg := tool.NewRegistry()
266 reg.Add(fakeTool{name: "read_file", readOnly: true})
267 reg.Add(fakeTool{name: "compress", readOnly: true})
268
269 calls := []provider.ToolCall{{Name: "read_file"}, {Name: "compress"}, {Name: "read_file"}}
270 got := partitionToolCalls(reg, calls)
271 want := []toolCallBatch{
272 {start: 0, end: 1, parallel: true},
273 {start: 1, end: 2},
274 {start: 2, end: 3, parallel: true},
275 }
276 if !reflect.DeepEqual(got, want) {
277 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
278 }
279 }
280
281 func TestPartitionToolCallsTodoWriteSerial(t *testing.T) {
282 reg := tool.NewRegistry()
283 reg.Add(fakeTool{name: "read_file", readOnly: true})
284 reg.Add(fakeTool{name: "todo_write", readOnly: true})
285
286 calls := []provider.ToolCall{{Name: "read_file"}, {Name: "todo_write"}, {Name: "read_file"}}
287 got := partitionToolCalls(reg, calls)
288 want := []toolCallBatch{
289 {start: 0, end: 1, parallel: true},
290 {start: 1, end: 2},
291 {start: 2, end: 3, parallel: true},
292 }
293 if !reflect.DeepEqual(got, want) {
294 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
295 }
296 }
297
298 func TestPartitionToolCallsBackgroundCollectorsSerial(t *testing.T) {
299 reg := tool.NewRegistry()
300 reg.Add(fakeTool{name: "read_file", readOnly: true})
301 reg.Add(fakeTool{name: "wait", readOnly: true})
302 reg.Add(fakeTool{name: "bash_output", readOnly: true})
303
304 calls := []provider.ToolCall{{Name: "read_file"}, {Name: "wait"}, {Name: "bash_output"}, {Name: "read_file"}}
305 got := partitionToolCalls(reg, calls)
306 want := []toolCallBatch{
307 {start: 0, end: 1, parallel: true},
308 {start: 1, end: 2},
309 {start: 2, end: 3},
310 {start: 3, end: 4, parallel: true},
311 }
312 if !reflect.DeepEqual(got, want) {
313 t.Fatalf("partitionToolCalls = %+v, want %+v", got, want)
314 }
315 }
316
317 // TestExecuteBatchParallelReadOnly checks that three 80ms read-only calls
318 // complete in well under 3×80ms — the wall-clock proof of true parallelism.
319 func TestExecuteBatchParallelReadOnly(t *testing.T) {
320 const delay = 80 * time.Millisecond
321 calls := int32(0)
322 reg := tool.NewRegistry()
323 reg.Add(fakeTool{name: "a", readOnly: true, delay: delay, calls: &calls})
324 reg.Add(fakeTool{name: "b", readOnly: true, delay: delay, calls: &calls})
325 reg.Add(fakeTool{name: "c", readOnly: true, delay: delay, calls: &calls})
326
327 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
328
329 start := time.Now()
330 batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{Name: "a"}, {Name: "b"}, {Name: "c"}})
331 results := batch.results
332 elapsed := time.Since(start)
333
334 if calls != 3 {
335 t.Errorf("dispatched %d calls, want 3", calls)
336 }
337 if len(results) != 3 || results[0] != "a done" || results[1] != "b done" || results[2] != "c done" {
338 t.Errorf("results out of order or wrong: %v", results)
339 }
340 // Allow generous slack for CI; even 2x serial would prove we got parallelism.
341 if elapsed >= 2*delay {
342 t.Errorf("read-only batch took %v (>= %v) — not parallel", elapsed, 2*delay)
343 }
344 }
345
346 func TestExecuteBatchDoesNotRunAnyToolWhenDispatchPersistenceFails(t *testing.T) {
347 wantErr := errors.New("ledger unavailable")
348 var calls int32
349 reg := tool.NewRegistry()
350 reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &calls})
351 reg.Add(fakeTool{name: "write_file", calls: &calls})
352 a := New(nil, reg, NewSession(""), Options{}, failingToolDispatchSink{err: wantErr})
353 batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{
354 {ID: "read", Name: "read_file"},
355 {ID: "write", Name: "write_file"},
356 })
357 if !errors.Is(batch.err, wantErr) {
358 t.Fatalf("batch error = %v, want %v", batch.err, wantErr)
359 }
360 if got := atomic.LoadInt32(&calls); got != 0 {
361 t.Fatalf("executed %d tools after dispatch persistence failed", got)
362 }
363 }
364
365 func TestExecuteBatchStampsToolResultTimestamps(t *testing.T) {
366 const delay = 30 * time.Millisecond
367 reg := tool.NewRegistry()
368 reg.Add(fakeTool{name: "a", readOnly: true, delay: delay})
369
370 sink := &recordSink{}
371 a := New(nil, reg, NewSession(""), Options{}, sink)
372
373 before := time.Now().UnixMilli()
374 a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{Name: "a"}})
375 after := time.Now().UnixMilli()
376
377 results := sink.kinds(event.ToolResult)
378 if len(results) != 1 {
379 t.Fatalf("got %d tool results, want 1", len(results))
380 }
381 tr := results[0].Tool
382 if tr.StartedAt < before || tr.StartedAt > after {
383 t.Errorf("StartedAt = %d, want within [%d, %d]", tr.StartedAt, before, after)
384 }
385 if tr.EndedAt != tr.StartedAt+tr.DurationMs {
386 t.Errorf("EndedAt = %d, want StartedAt+DurationMs = %d", tr.EndedAt, tr.StartedAt+tr.DurationMs)
387 }
388 if tr.DurationMs < delay.Milliseconds() {
389 t.Errorf("DurationMs = %d, want >= %d", tr.DurationMs, delay.Milliseconds())
390 }
391 }
392
393 func TestExecuteBatchMarksOnlyExecutedWritersForWorkspaceRefresh(t *testing.T) {
394 reg := tool.NewRegistry()
395 reg.Add(fakeTool{name: "write_file"})
396 reg.Add(fakeTool{name: "read_file", readOnly: true})
397 sink := &recordSink{}
398 a := New(nil, reg, NewSession(""), Options{}, sink)
399 a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{
400 {Name: "write_file", Arguments: `{"path":"pkg/main.go","content":"x"}`},
401 {Name: "read_file", Arguments: `{"path":"pkg/main.go"}`},
402 })
403 results := sink.kinds(event.ToolResult)
404 if len(results) != 2 {
405 t.Fatalf("got %d results, want 2", len(results))
406 }
407 if !results[0].Tool.WorkspaceMutation || len(results[0].Tool.WorkspacePaths) != 1 || results[0].Tool.WorkspacePaths[0] != "pkg/main.go" {
408 t.Fatalf("writer metadata = %+v", results[0].Tool)
409 }
410 if results[1].Tool.WorkspaceMutation {
411 t.Fatalf("read-only call was marked as mutation: %+v", results[1].Tool)
412 }
413 }
414
415 func TestExecuteBatchMarksFailedWriterForWorkspaceRefresh(t *testing.T) {
416 reg := tool.NewRegistry()
417 reg.Add(fakeTool{name: "write_file", err: errors.New("partial write")})
418 sink := &recordSink{}
419 a := New(nil, reg, NewSession(""), Options{}, sink)
420 a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{Name: "write_file", Arguments: `{"path":"partial.go"}`}})
421 results := sink.kinds(event.ToolResult)
422 if len(results) != 1 || !results[0].Tool.WorkspaceMutation {
423 t.Fatalf("failed writer did not invalidate workspace: %+v", results)
424 }
425 }
426
427 func TestExecuteBatchPublishesWorkspaceMutationBeforeLaterToolCompletes(t *testing.T) {
428 started := make(chan struct{})
429 release := make(chan struct{})
430 reg := tool.NewRegistry()
431 reg.Add(fakeTool{name: "write_file"})
432 reg.Add(blockingTool{name: "slow_read", started: started, release: release})
433 sink := newWorkspaceSignalSink()
434 a := New(nil, reg, NewSession(""), Options{}, sink)
435 done := make(chan struct{})
436 go func() {
437 defer close(done)
438 a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{
439 {Name: "write_file", Arguments: `{"path":"ready.go","content":"x"}`},
440 {Name: "slow_read", Arguments: `{}`},
441 })
442 }()
443
444 select {
445 case <-started:
446 case <-time.After(2 * time.Second):
447 t.Fatal("later tool did not start")
448 }
449 select {
450 case mutation := <-sink.mutations:
451 if mutation.ToolName != "write_file" || len(mutation.Paths) != 1 || mutation.Paths[0] != "ready.go" {
452 t.Fatalf("workspace mutation = %+v", mutation)
453 }
454 case <-time.After(2 * time.Second):
455 t.Fatal("writer completion did not publish before the later tool completed")
456 }
457 if results := sink.kinds(event.ToolResult); len(results) != 1 || results[0].Tool.Name != "write_file" {
458 t.Fatalf("completed writer must be checkpointed before the next tool: %+v", results)
459 }
460 close(release)
461 select {
462 case <-done:
463 case <-time.After(2 * time.Second):
464 t.Fatal("batch did not finish after releasing the later tool")
465 }
466 }
467
468 func TestWorkspaceMutationClassifierTreatsGitCommitAsGitMetadata(t *testing.T) {
469 mutation, ok := workspaceMutationForCall("call", "bash", json.RawMessage(`{"command":"git commit -m test"}`), false)
470 if !ok || !mutation.GitMeta || mutation.Content || mutation.WorkingTree || mutation.Tree || !mutation.AllPaths {
471 t.Fatalf("git commit workspace invalidation = %+v, ok=%v", mutation, ok)
472 }
473 mutation, ok = workspaceMutationForCall("call", "bash", json.RawMessage(`{"command":"git commit -am test"}`), false)
474 if !ok || !mutation.GitMeta || !mutation.Content || !mutation.WorkingTree || !mutation.Tree {
475 t.Fatalf("content-writing git commit invalidation = %+v, ok=%v", mutation, ok)
476 }
477 if mutation, ok = workspaceMutationForCall("call", "bash", json.RawMessage(`{"command":"go test ./..."}`), false); ok {
478 t.Fatalf("ordinary verifier invalidated durable workspace state: %+v", mutation)
479 }
480 if mutation, ok = workspaceMutationForCall("call", "bash", json.RawMessage(`{"command":"date --set tomorrow"}`), false); ok {
481 t.Fatalf("host-only state write invalidated the workspace: %+v", mutation)
482 }
483 if mutation, ok = workspaceMutationForCall("call", "remember", json.RawMessage(`{"name":"preference"}`), false); ok {
484 t.Fatalf("host-only memory write invalidated the workspace: %+v", mutation)
485 }
486 }
487
488 func TestExecuteBatchCancelledCallsCarryNoTimestamps(t *testing.T) {
489 reg := tool.NewRegistry()
490 reg.Add(fakeTool{name: "a", readOnly: true})
491
492 sink := &recordSink{}
493 a := New(nil, reg, NewSession(""), Options{}, sink)
494
495 ctx, cancel := context.WithCancel(context.Background())
496 cancel()
497 a.executeBatch(ctx, &a.turn, []provider.ToolCall{{Name: "a"}})
498
499 results := sink.kinds(event.ToolResult)
500 if len(results) != 1 {
501 t.Fatalf("got %d tool results, want 1", len(results))
502 }
503 tr := results[0].Tool
504 if tr.StartedAt != 0 || tr.EndedAt != 0 {
505 t.Errorf("never-ran call has StartedAt=%d EndedAt=%d, want both zero", tr.StartedAt, tr.EndedAt)
506 }
507 }
508
509 // TestExecuteBatchSegmentsAroundWrites ensures a write call only serializes its
510 // own position in the provider-ordered batch: read-only runs before and after it
511 // may still parallelise within their contiguous segments.
512 func TestExecuteBatchSegmentsAroundWrites(t *testing.T) {
513 // A larger per-call delay keeps fixed scheduler jitter on loaded CI a small
514 // fraction of the segment time, so the tight relative bound below stays
515 // reliable instead of being widened toward the serial floor.
516 const delay = 150 * time.Millisecond
517 reg := tool.NewRegistry()
518 reg.Add(fakeTool{name: "ro1", readOnly: true, delay: delay})
519 reg.Add(fakeTool{name: "ro2", readOnly: true, delay: delay})
520 reg.Add(fakeTool{name: "ro3", readOnly: true, delay: delay})
521 reg.Add(fakeTool{name: "ro4", readOnly: true, delay: delay})
522 reg.Add(fakeTool{name: "rw", readOnly: false, delay: delay})
523
524 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
525
526 start := time.Now()
527 batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{
528 {Name: "ro1"},
529 {Name: "ro2"},
530 {Name: "rw"},
531 {Name: "ro3"},
532 {Name: "ro4"},
533 })
534 results := batch.results
535 elapsed := time.Since(start)
536
537 want := []string{"ro1 done", "ro2 done", "rw done", "ro3 done", "ro4 done"}
538 if len(results) != len(want) {
539 t.Fatalf("got %d results, want %d: %v", len(results), len(want), results)
540 }
541 for i := range want {
542 if stripReceiptCitation(results[i]) != want[i] {
543 t.Fatalf("results out of order or wrong: got %v want %v", results, want)
544 }
545 }
546 // Desired shape is roughly 3*delay: (ro1|ro2), then rw, then (ro3|ro4).
547 // Old all-serial behaviour is roughly 5*delay and should fail this bound.
548 if elapsed >= 4*delay {
549 t.Errorf("mixed batch took %v (>= %v) — read-only segments did not parallelise", elapsed, 4*delay)
550 }
551 if elapsed < 2*delay {
552 t.Errorf("mixed batch took only %v — write call appears to have overlapped a read-only segment", elapsed)
553 }
554 }
555
556 func TestExecuteBatchPairsRetiredCompleteStepWithoutBlockingPeers(t *testing.T) {
557 reg := tool.NewRegistry()
558 reg.Add(fakeTool{name: "bash", readOnly: false})
559 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
560
561 a.SeedTodoState([]evidence.TodoItem{{Content: "Run checks", Status: "pending"}})
562 batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{
563 {Name: "bash", Arguments: `{"command":"go test ./internal/..."}`},
564 {Name: "complete_step", Arguments: `{
565 "step":"Run checks",
566 "result":"checks passed",
567 "evidence":[{"kind":"verification","summary":"tests passed","command":"go test ./internal/..."}]
568 }`},
569 })
570 results := batch.results
571
572 if len(results) != 2 {
573 t.Fatalf("got %d results, want 2", len(results))
574 }
575 if !strings.Contains(results[1], "tool_retired") || !a.task.ledger.HasSuccessfulCommand("go test ./internal/...") {
576 t.Fatalf("retired result=%q bash receipt missing=%v", results[1], !a.task.ledger.HasSuccessfulCommand("go test ./internal/..."))
577 }
578 }
579
580 func TestExecuteOneFailedReceiptDoesNotVerify(t *testing.T) {
581 reg := tool.NewRegistry()
582 reg.Add(fakeTool{name: "bash", readOnly: false, err: errors.New("boom")})
583 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
584
585 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "bash", Arguments: `{"command":"go test ./..."}`})
586 if out.errMsg == "" {
587 t.Fatal("failing fake tool should return an error outcome")
588 }
589 if a.task.ledger.HasSuccessfulCommand("go test ./...") {
590 t.Fatal("failed bash receipt must not verify")
591 }
592 }
593
594 func TestRunResetsEvidenceLedger(t *testing.T) {
595 reg := tool.NewRegistry()
596 reg.Add(fakeTool{name: "bash", readOnly: false})
597 prov := &mockProvider{name: "p", chunks: []provider.Chunk{{Type: provider.ChunkText, Text: "done"}}}
598 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
599
600 a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "bash", Arguments: `{"command":"go test ./..."}`})
601 if !a.task.ledger.HasSuccessfulCommand("go test ./...") {
602 t.Fatal("setup failed to record evidence")
603 }
604
605 if err := a.Run(context.Background(), "next turn"); err != nil {
606 t.Fatalf("Run: %v", err)
607 }
608 if a.task.ledger.HasSuccessfulCommand("go test ./...") {
609 t.Fatal("new user turn should not inherit previous receipts")
610 }
611 }
612
612 lines GO