Initial commit
diff --git a/loop/mocks.go b/loop/mocks.go
new file mode 100644
index 0000000..264c6bc
--- /dev/null
+++ b/loop/mocks.go
@@ -0,0 +1,209 @@
+package loop
+
+import (
+	"context"
+	"reflect"
+	"sync"
+	"testing"
+
+	"sketch.dev/ant"
+)
+
+// MockConvo is a custom mock for ant.Convo interface
+type MockConvo struct {
+	mu sync.Mutex
+	t  *testing.T
+
+	// Maps method name to a list of calls with arguments and return values
+	calls map[string][]*mockCall
+	// Maps method name to expected calls
+	expectations map[string][]*mockExpectation
+}
+
+type mockCall struct {
+	args   []interface{}
+	result []interface{}
+}
+
+type mockExpectation struct {
+	until  chan any
+	args   []interface{}
+	result []interface{}
+}
+
+// Return sets up return values for an expectation
+func (e *mockExpectation) Return(values ...interface{}) {
+	e.result = values
+}
+
+// Return sets up return values for an expectation
+func (e *mockExpectation) BlockAndReturn(until chan any, values ...interface{}) {
+	e.until = until
+	e.result = values
+}
+
+// NewMockConvo creates a new mock Convo
+func NewMockConvo(t *testing.T) *MockConvo {
+	return &MockConvo{
+		t:            t,
+		mu:           sync.Mutex{},
+		calls:        make(map[string][]*mockCall),
+		expectations: make(map[string][]*mockExpectation),
+	}
+}
+
+// ExpectCall sets up an expectation for a method call
+func (m *MockConvo) ExpectCall(method string, args ...interface{}) *mockExpectation {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	expectation := &mockExpectation{args: args}
+	if _, ok := m.expectations[method]; !ok {
+		m.expectations[method] = []*mockExpectation{}
+	}
+	m.expectations[method] = append(m.expectations[method], expectation)
+	return expectation
+}
+
+// findMatchingExpectation finds a matching expectation for a method call
+func (m *MockConvo) findMatchingExpectation(method string, args ...interface{}) (*mockExpectation, bool) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	expectations, ok := m.expectations[method]
+	if !ok {
+		return nil, false
+	}
+
+	for i, exp := range expectations {
+		if matchArgs(exp.args, args) {
+			if exp.until != nil {
+				<-exp.until
+			}
+			// Remove the matched expectation
+			m.expectations[method] = append(expectations[:i], expectations[i+1:]...)
+			return exp, true
+		}
+	}
+	return nil, false
+}
+
+// matchArgs checks if call arguments match expectation arguments
+func matchArgs(expected, actual []interface{}) bool {
+	if len(expected) != len(actual) {
+		return false
+	}
+
+	for i, exp := range expected {
+		// Special case: nil matches anything
+		if exp == nil {
+			continue
+		}
+
+		// Check for equality
+		if !reflect.DeepEqual(exp, actual[i]) {
+			return false
+		}
+	}
+	return true
+}
+
+// recordCall records a method call
+func (m *MockConvo) recordCall(method string, args ...interface{}) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if _, ok := m.calls[method]; !ok {
+		m.calls[method] = []*mockCall{}
+	}
+	m.calls[method] = append(m.calls[method], &mockCall{args: args})
+}
+
+func (m *MockConvo) SendMessage(message ant.Message) (*ant.MessageResponse, error) {
+	m.recordCall("SendMessage", message)
+	exp, ok := m.findMatchingExpectation("SendMessage", message)
+	if !ok {
+		m.t.Errorf("unexpected call to SendMessage: %+v", message)
+		m.t.FailNow()
+	}
+	var retErr error
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if err, ok := exp.result[1].(error); ok {
+		retErr = err
+	}
+	return exp.result[0].(*ant.MessageResponse), retErr
+}
+
+func (m *MockConvo) SendUserTextMessage(message string, otherContents ...ant.Content) (*ant.MessageResponse, error) {
+	m.recordCall("SendUserTextMessage", message, otherContents)
+	exp, ok := m.findMatchingExpectation("SendUserTextMessage", message, otherContents)
+	if !ok {
+		m.t.Error("unexpected call to SendUserTextMessage")
+		m.t.FailNow()
+	}
+	var retErr error
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if err, ok := exp.result[1].(error); ok {
+		retErr = err
+	}
+	return exp.result[0].(*ant.MessageResponse), retErr
+}
+
+func (m *MockConvo) ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error) {
+	m.recordCall("ToolResultContents", resp)
+	exp, ok := m.findMatchingExpectation("ToolResultContents", resp)
+	if !ok {
+		m.t.Error("unexpected call to ToolResultContents")
+		m.t.FailNow()
+	}
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	var retErr error
+	if err, ok := exp.result[1].(error); ok {
+		retErr = err
+	}
+
+	return exp.result[0].([]ant.Content), retErr
+}
+
+func (m *MockConvo) ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error) {
+	m.recordCall("ToolResultCancelContents", resp)
+	exp, ok := m.findMatchingExpectation("ToolResultCancelContents", resp)
+	if !ok {
+		m.t.Error("unexpected call to ToolResultCancelContents")
+		m.t.FailNow()
+	}
+	var retErr error
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if err, ok := exp.result[1].(error); ok {
+		retErr = err
+	}
+
+	return exp.result[0].([]ant.Content), retErr
+}
+
+func (m *MockConvo) CumulativeUsage() ant.CumulativeUsage {
+	m.recordCall("CumulativeUsage")
+	return ant.CumulativeUsage{}
+}
+
+func (m *MockConvo) OverBudget() error {
+	m.recordCall("OverBudget")
+	return nil
+}
+
+func (m *MockConvo) ResetBudget(_ ant.Budget) {
+	m.recordCall("ResetBudget")
+}
+
+// AssertExpectations checks that all expectations were met
+func (m *MockConvo) AssertExpectations(t *testing.T) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for method, expectations := range m.expectations {
+		if len(expectations) > 0 {
+			t.Errorf("not all expectations were met for method %s:", method)
+		}
+	}
+}