返回 DeepSeek-Reasonix
mock_provider.go
根目录 / internal / agent / testutil / mock_provider.go
1 // Package testutil provides reusable test helpers for the agent package.
2 // MockProvider replaces a real LLM backend in agent tests with scripted
3 // responses, request recording, and error injection — so the harness
4 // loop, cache behaviour, and tool dispatch can be verified without
5 // network calls.
6 package testutil
7
8 import (
9 "context"
10 "fmt"
11 "sync"
12
13 "reasonix/internal/provider"
14 )
15
16 // Turn describes one expected Stream call: the text, optional reasoning,
17 // optional tool calls, usage telemetry, and optionally an error to inject.
18 type Turn struct {
19 Text string
20 Reasoning string
21 ToolCalls []provider.ToolCall
22 Usage *provider.Usage
23 // Chunks, when non-empty, is emitted exactly as provided. It is useful for
24 // edge cases such as partial tool-call starts followed by an error.
25 Chunks []provider.Chunk
26
27 // StreamError, when set, causes Stream to return this error before any
28 // chunks, simulating a network or auth failure for that turn.
29 StreamError error
30 // ChunkError, when set, is emitted after the scripted chunks, simulating a
31 // mid-stream provider failure after partial output has reached the agent.
32 ChunkError error
33 }
34
35 // MockProvider is a provider.Provider whose Stream returns scripted
36 // responses, one Turn per call. It records every request it receives so
37 // tests can inspect what was sent to the model (cache surface, tool
38 // schemas, message ordering).
39 //
40 // Usage:
41 //
42 // mp := NewMock("test-model", Turn{Text: "Hello"}).Record()
43 // agent := agent.New(mp, registry, session, opts, nil)
44 // agent.Run(ctx, "hi")
45 //
46 // for i, req := range mp.Requests() {
47 // fmt.Printf("turn %d: %d messages, %d tools\n", i+1,
48 // len(req.Messages), len(req.Tools))
49 // }
50 type MockProvider struct {
51 mu sync.Mutex
52 name string
53 script []Turn
54 seen int
55 reqs []provider.Request
56 }
57
58 // NewMock creates a MockProvider. The turns argument is the script; each
59 // Stream call consumes one Turn. Extra calls after the script are exhausted
60 // return an error. Use Append or SetScript to add more turns later.
61 func NewMock(name string, turns ...Turn) *MockProvider {
62 return &MockProvider{name: name, script: turns}
63 }
64
65 // Name returns the provider instance name.
66 func (p *MockProvider) Name() string { return p.name }
67
68 // Stream replays the next scripted turn. It records the request, then
69 // sends chunks in order (reasoning → text → tool calls → usage → done).
70 // If the Turn has StreamError set it is returned immediately.
71 func (p *MockProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
72 if err := ctx.Err(); err != nil {
73 return nil, err
74 }
75
76 p.mu.Lock()
77 p.reqs = append(p.reqs, req)
78 if p.seen >= len(p.script) {
79 p.mu.Unlock()
80 return nil, fmt.Errorf("MockProvider[%s]: no scripted turn %d (have %d turns)", p.name, p.seen, len(p.script))
81 }
82 t := p.script[p.seen]
83 p.seen++
84 p.mu.Unlock()
85
86 if t.StreamError != nil {
87 return nil, t.StreamError
88 }
89
90 var chunks []provider.Chunk
91 if len(t.Chunks) > 0 {
92 chunks = append(chunks, t.Chunks...)
93 } else {
94 if t.Reasoning != "" {
95 chunks = append(chunks, provider.Chunk{Type: provider.ChunkReasoning, Text: t.Reasoning})
96 }
97 if t.Text != "" {
98 chunks = append(chunks, provider.Chunk{Type: provider.ChunkText, Text: t.Text})
99 }
100 for i := range t.ToolCalls {
101 tc := t.ToolCalls[i]
102 chunks = append(chunks, provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &tc})
103 }
104 if t.Usage != nil {
105 chunks = append(chunks, provider.Chunk{Type: provider.ChunkUsage, Usage: t.Usage})
106 }
107 if t.ChunkError != nil {
108 chunks = append(chunks, provider.Chunk{Type: provider.ChunkError, Err: t.ChunkError})
109 } else {
110 chunks = append(chunks, provider.Chunk{Type: provider.ChunkDone})
111 }
112 }
113
114 ch := make(chan provider.Chunk)
115 go func() {
116 defer close(ch)
117 for _, c := range chunks {
118 if err := ctx.Err(); err != nil {
119 ch <- provider.Chunk{Type: provider.ChunkError, Err: err}
120 return
121 }
122 select {
123 case <-ctx.Done():
124 ch <- provider.Chunk{Type: provider.ChunkError, Err: ctx.Err()}
125 return
126 case ch <- c:
127 }
128 }
129 }()
130 return ch, nil
131 }
132
133 // Requests returns all recorded requests in call order. Safe to call from
134 // any goroutine after the run loop finishes.
135 func (p *MockProvider) Requests() []provider.Request {
136 p.mu.Lock()
137 defer p.mu.Unlock()
138 out := make([]provider.Request, len(p.reqs))
139 copy(out, p.reqs)
140 return out
141 }
142
143 // LastRequest returns the most recent request, or nil if none.
144 func (p *MockProvider) LastRequest() *provider.Request {
145 p.mu.Lock()
146 defer p.mu.Unlock()
147 if len(p.reqs) == 0 {
148 return nil
149 }
150 r := p.reqs[len(p.reqs)-1]
151 return &r
152 }
153
154 // MessageCount is a shortcut for len(Requests()).
155 func (p *MockProvider) CallCount() int {
156 p.mu.Lock()
157 defer p.mu.Unlock()
158 return p.seen
159 }
160
161 // SetScript replaces the script and resets the call counter.
162 func (p *MockProvider) SetScript(turns ...Turn) {
163 p.mu.Lock()
164 defer p.mu.Unlock()
165 p.script = turns
166 p.seen = 0
167 }
168
169 // Append adds turns to the existing script.
170 func (p *MockProvider) Append(turns ...Turn) {
171 p.mu.Lock()
172 defer p.mu.Unlock()
173 p.script = append(p.script, turns...)
174 }
175
176 // Reset clears recorded requests and the call counter without changing the script.
177 func (p *MockProvider) Reset() {
178 p.mu.Lock()
179 defer p.mu.Unlock()
180 p.reqs = nil
181 p.seen = 0
182 }
183
184 // UsageTurn is a convenience: a Turn whose text is empty but usage is set.
185 // Useful for simulating a tool-call round-trip where the final model response
186 // that round is tested later.
187 func UsageTurn(hit, miss, completion int) Turn {
188 return Turn{
189 Usage: &provider.Usage{
190 CacheHitTokens: hit,
191 CacheMissTokens: miss,
192 CompletionTokens: completion,
193 PromptTokens: hit + miss,
194 TotalTokens: hit + miss + completion,
195 },
196 }
197 }
198
199 // ErrorTurn is a convenience: a Turn that immediately returns the given error.
200 func ErrorTurn(err error) Turn {
201 return Turn{StreamError: err}
202 }
203
204 var _ provider.Provider = (*MockProvider)(nil)
205
205 lines GO