// Package testutil provides reusable test helpers for the agent package. // MockProvider replaces a real LLM backend in agent tests with scripted // responses, request recording, and error injection — so the harness // loop, cache behaviour, and tool dispatch can be verified without // network calls. package testutil import ( "context" "fmt" "sync" "reasonix/internal/provider" ) // Turn describes one expected Stream call: the text, optional reasoning, // optional tool calls, usage telemetry, and optionally an error to inject. type Turn struct { Text string Reasoning string ToolCalls []provider.ToolCall Usage *provider.Usage // Chunks, when non-empty, is emitted exactly as provided. It is useful for // edge cases such as partial tool-call starts followed by an error. Chunks []provider.Chunk // StreamError, when set, causes Stream to return this error before any // chunks, simulating a network or auth failure for that turn. StreamError error // ChunkError, when set, is emitted after the scripted chunks, simulating a // mid-stream provider failure after partial output has reached the agent. ChunkError error } // MockProvider is a provider.Provider whose Stream returns scripted // responses, one Turn per call. It records every request it receives so // tests can inspect what was sent to the model (cache surface, tool // schemas, message ordering). // // Usage: // // mp := NewMock("test-model", Turn{Text: "Hello"}).Record() // agent := agent.New(mp, registry, session, opts, nil) // agent.Run(ctx, "hi") // // for i, req := range mp.Requests() { // fmt.Printf("turn %d: %d messages, %d tools\n", i+1, // len(req.Messages), len(req.Tools)) // } type MockProvider struct { mu sync.Mutex name string script []Turn seen int reqs []provider.Request } // NewMock creates a MockProvider. The turns argument is the script; each // Stream call consumes one Turn. Extra calls after the script are exhausted // return an error. Use Append or SetScript to add more turns later. func NewMock(name string, turns ...Turn) *MockProvider { return &MockProvider{name: name, script: turns} } // Name returns the provider instance name. func (p *MockProvider) Name() string { return p.name } // Stream replays the next scripted turn. It records the request, then // sends chunks in order (reasoning → text → tool calls → usage → done). // If the Turn has StreamError set it is returned immediately. func (p *MockProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) { if err := ctx.Err(); err != nil { return nil, err } p.mu.Lock() p.reqs = append(p.reqs, req) if p.seen >= len(p.script) { p.mu.Unlock() return nil, fmt.Errorf("MockProvider[%s]: no scripted turn %d (have %d turns)", p.name, p.seen, len(p.script)) } t := p.script[p.seen] p.seen++ p.mu.Unlock() if t.StreamError != nil { return nil, t.StreamError } var chunks []provider.Chunk if len(t.Chunks) > 0 { chunks = append(chunks, t.Chunks...) } else { if t.Reasoning != "" { chunks = append(chunks, provider.Chunk{Type: provider.ChunkReasoning, Text: t.Reasoning}) } if t.Text != "" { chunks = append(chunks, provider.Chunk{Type: provider.ChunkText, Text: t.Text}) } for i := range t.ToolCalls { tc := t.ToolCalls[i] chunks = append(chunks, provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &tc}) } if t.Usage != nil { chunks = append(chunks, provider.Chunk{Type: provider.ChunkUsage, Usage: t.Usage}) } if t.ChunkError != nil { chunks = append(chunks, provider.Chunk{Type: provider.ChunkError, Err: t.ChunkError}) } else { chunks = append(chunks, provider.Chunk{Type: provider.ChunkDone}) } } ch := make(chan provider.Chunk) go func() { defer close(ch) for _, c := range chunks { if err := ctx.Err(); err != nil { ch <- provider.Chunk{Type: provider.ChunkError, Err: err} return } select { case <-ctx.Done(): ch <- provider.Chunk{Type: provider.ChunkError, Err: ctx.Err()} return case ch <- c: } } }() return ch, nil } // Requests returns all recorded requests in call order. Safe to call from // any goroutine after the run loop finishes. func (p *MockProvider) Requests() []provider.Request { p.mu.Lock() defer p.mu.Unlock() out := make([]provider.Request, len(p.reqs)) copy(out, p.reqs) return out } // LastRequest returns the most recent request, or nil if none. func (p *MockProvider) LastRequest() *provider.Request { p.mu.Lock() defer p.mu.Unlock() if len(p.reqs) == 0 { return nil } r := p.reqs[len(p.reqs)-1] return &r } // MessageCount is a shortcut for len(Requests()). func (p *MockProvider) CallCount() int { p.mu.Lock() defer p.mu.Unlock() return p.seen } // SetScript replaces the script and resets the call counter. func (p *MockProvider) SetScript(turns ...Turn) { p.mu.Lock() defer p.mu.Unlock() p.script = turns p.seen = 0 } // Append adds turns to the existing script. func (p *MockProvider) Append(turns ...Turn) { p.mu.Lock() defer p.mu.Unlock() p.script = append(p.script, turns...) } // Reset clears recorded requests and the call counter without changing the script. func (p *MockProvider) Reset() { p.mu.Lock() defer p.mu.Unlock() p.reqs = nil p.seen = 0 } // UsageTurn is a convenience: a Turn whose text is empty but usage is set. // Useful for simulating a tool-call round-trip where the final model response // that round is tested later. func UsageTurn(hit, miss, completion int) Turn { return Turn{ Usage: &provider.Usage{ CacheHitTokens: hit, CacheMissTokens: miss, CompletionTokens: completion, PromptTokens: hit + miss, TotalTokens: hit + miss + completion, }, } } // ErrorTurn is a convenience: a Turn that immediately returns the given error. func ErrorTurn(err error) Turn { return Turn{StreamError: err} } var _ provider.Provider = (*MockProvider)(nil)