Files
2026-07-13 13:00:08 +08:00

205 lines
5.8 KiB
Go

// 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)