| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "sync" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | "reasonix/internal/provider" |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | type toolReceiptSignalSink struct { |
| 15 | mu sync.Mutex |
| 16 | events []event.Event |
| 17 | previews chan event.Event |
| 18 | } |
| 19 | |
| 20 | func (s *toolReceiptSignalSink) Emit(e event.Event) { |
| 21 | s.mu.Lock() |
| 22 | s.events = append(s.events, e) |
| 23 | s.mu.Unlock() |
| 24 | if e.Kind == event.ToolResultPreview { |
| 25 | s.previews <- e |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func (s *toolReceiptSignalSink) kinds(kind event.Kind) []event.Event { |
| 30 | s.mu.Lock() |
| 31 | defer s.mu.Unlock() |
| 32 | var out []event.Event |
| 33 | for _, e := range s.events { |
| 34 | if e.Kind == kind { |
| 35 | out = append(out, e) |
| 36 | } |
| 37 | } |
| 38 | return out |
| 39 | } |
| 40 | |
| 41 | func TestTodoResultCommitsBeforeFollowingToolWithoutPreviewState(t *testing.T) { |
| 42 | started := make(chan struct{}) |
| 43 | release := make(chan struct{}) |
| 44 | reg := tool.NewRegistry() |
| 45 | reg.Add(fakeTool{name: "todo_write", readOnly: true}) |
| 46 | reg.Add(blockingTool{name: "slow_read", started: started, release: release}) |
| 47 | sink := &toolReceiptSignalSink{previews: make(chan event.Event, 1)} |
| 48 | a := New(nil, reg, NewSession(""), Options{}, sink) |
| 49 | done := make(chan struct{}) |
| 50 | go func() { |
| 51 | defer close(done) |
| 52 | a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{ |
| 53 | {ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"Ship the fix","status":"in_progress"}]}`}, |
| 54 | {ID: "read-1", Name: "slow_read", Arguments: `{}`}, |
| 55 | }) |
| 56 | }() |
| 57 | |
| 58 | select { |
| 59 | case <-started: |
| 60 | case <-time.After(2 * time.Second): |
| 61 | t.Fatal("later tool did not start") |
| 62 | } |
| 63 | select { |
| 64 | case preview := <-sink.previews: |
| 65 | t.Fatalf("todo state leaked through a transient preview: %+v", preview.Tool) |
| 66 | default: |
| 67 | } |
| 68 | if results := sink.kinds(event.ToolResult); len(results) != 1 || results[0].Tool.Name != "todo_write" { |
| 69 | t.Fatalf("completed todo result must be checkpointed before the next tool: %+v", results) |
| 70 | } |
| 71 | |
| 72 | close(release) |
| 73 | select { |
| 74 | case <-done: |
| 75 | case <-time.After(2 * time.Second): |
| 76 | t.Fatal("batch did not finish after releasing the later tool") |
| 77 | } |
| 78 | if previews := sink.kinds(event.ToolResultPreview); len(previews) != 0 { |
| 79 | t.Fatalf("ToolResultPreview events = %d, want 0", len(previews)) |
| 80 | } |
| 81 | results := sink.kinds(event.ToolResult) |
| 82 | if len(results) != 2 || results[0].Tool.ID != "todo-1" || results[1].Tool.ID != "read-1" { |
| 83 | t.Fatalf("provider-ordered ToolResult events = %+v", results) |
| 84 | } |
| 85 | } |
| 86 |