205 lines
5.8 KiB
Go
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)
|