1package core
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "sync/atomic"
9 "testing"
10 "time"
11 "unicode/utf8"
12
13 "github.com/bnema/zut/packages/provider"
14)
15
16type retryFakeClient struct {
17 calls int32
18}
19
20func (c *retryFakeClient) Name() string { return "retry-fake" }
21
22func (c *retryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
23 if req.Lifecycle != nil {
24 req.Lifecycle.RequestAttempt(1, 1)
25 }
26 call := atomic.AddInt32(&c.calls, 1)
27 out := make(chan provider.Event, 4)
28 go func() {
29 defer close(out)
30 out <- provider.EventStart{Provider: "retry-fake", Model: req.Model}
31 if call == 1 {
32 out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("anthropic overloaded_error: Overloaded")}
33 return
34 }
35 out <- provider.EventTextDelta{Delta: "ok"}
36 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
37 Role: provider.RoleAssistant,
38 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
39 }}
40 }()
41 return out, nil
42}
43
44func TestAgentRetriesOverloadedStreamError(t *testing.T) {
45 client := &retryFakeClient{}
46 a := NewAgent(client, "fake-model", "system", Registry{})
47 a.RetryBaseDelay = time.Millisecond
48
49 var turnErrs []string
50 err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
51 if e, ok := ev.(EvTurnEnd); ok && e.Err != nil {
52 turnErrs = append(turnErrs, e.Err.Error())
53 }
54 })
55 if err != nil {
56 t.Fatalf("Prompt returned %v", err)
57 }
58 if got := atomic.LoadInt32(&client.calls); got != 2 {
59 t.Fatalf("Stream calls = %d; want 2", got)
60 }
61 if len(turnErrs) != 1 || !strings.Contains(turnErrs[0], "overloaded_error") {
62 t.Fatalf("turn errors = %v; want one overloaded error before retry", turnErrs)
63 }
64 msgs := a.Messages()
65 if len(msgs) != 2 {
66 t.Fatalf("message count = %d; want user + final assistant", len(msgs))
67 }
68 if got := extractText(msgs[1]); got != "ok" {
69 t.Fatalf("final assistant text = %q; want ok", got)
70 }
71}
72
73func TestAgentEmitsRetryLifecycleEvents(t *testing.T) {
74 client := &retryFakeClient{}
75 a := NewAgent(client, "fake-model", "system", Registry{})
76 a.RetryBaseDelay = time.Millisecond
77
78 var lifecycle []string
79 if err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
80 switch e := ev.(type) {
81 case EvRequestStarted:
82 lifecycle = append(lifecycle, fmt.Sprintf("request:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
83 case EvRetryScheduled:
84 lifecycle = append(lifecycle, fmt.Sprintf("retry:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
85 }
86 }); err != nil {
87 t.Fatalf("Prompt returned %v", err)
88 }
89
90 want := []string{
91 "request:agent:1/4",
92 "request:provider:1/1",
93 "retry:agent:2/4",
94 "request:agent:2/4",
95 "request:provider:1/1",
96 }
97 if strings.Join(lifecycle, ",") != strings.Join(want, ",") {
98 t.Fatalf("lifecycle = %v, want %v", lifecycle, want)
99 }
100}
101
102// codexRetryFakeClient reproduces a transient OpenAI Codex backend
103// failure on the first call and succeeds afterwards.
104type codexRetryFakeClient struct {
105 calls int32
106 firstErr string
107}
108
109func (c *codexRetryFakeClient) Name() string { return "openai-codex" }
110
111func (c *codexRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
112 call := atomic.AddInt32(&c.calls, 1)
113 out := make(chan provider.Event, 4)
114 go func() {
115 defer close(out)
116 out <- provider.EventStart{Provider: "openai-codex", Model: req.Model}
117 if call == 1 {
118 out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("%s", c.firstErr)}
119 return
120 }
121 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
122 Role: provider.RoleAssistant,
123 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
124 }}
125 }()
126 return out, nil
127}
128
129func TestAgentRetriesCodexProcessingError(t *testing.T) {
130 cases := []struct {
131 name string
132 err string
133 }{
134 {
135 name: "processing error",
136 err: "codex error: An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID 60c8ebbd-20bd-42e4-b756-6e844041cfc0 in your message.",
137 },
138 {
139 name: "servers overloaded",
140 err: "codex error: Our servers are currently overloaded. Please try again later.",
141 },
142 {
143 name: "try again later only",
144 err: "codex error: Something went wrong. Please try again later.",
145 },
146 }
147 for _, tc := range cases {
148 t.Run(tc.name, func(t *testing.T) {
149 client := &codexRetryFakeClient{firstErr: tc.err}
150 a := NewAgent(client, "gpt-5.6-sol", "system", Registry{})
151 a.RetryBaseDelay = time.Millisecond
152
153 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
154 t.Fatalf("Prompt returned %v; want retry to succeed", err)
155 }
156 if got := atomic.LoadInt32(&client.calls); got != 2 {
157 t.Fatalf("Stream calls = %d; want 2", got)
158 }
159 msgs := a.Messages()
160 if len(msgs) != 2 || extractText(msgs[1]) != "ok" {
161 t.Fatalf("messages = %v; want user + ok assistant", msgs)
162 }
163 })
164 }
165}
166
167// TestCanRetryErrorCapacityMessages pins the classification of Codex
168// capacity wording and makes sure quota/usage limits stay terminal even
169// when they carry "try again later" style advice.
170func TestCanRetryErrorCapacityMessages(t *testing.T) {
171 a := NewAgent(nil, "gpt-5.6-sol", "system", Registry{})
172 cases := []struct {
173 msg string
174 want bool
175 }{
176 {"codex error: Our servers are currently overloaded. Please try again later.", true},
177 {"codex error: Our servers are busy right now.", true},
178 {"codex error: Please try again later.", true},
179 {"codex error: You have hit your monthly usage limit. Try again later.", false},
180 {"codex error: quota exceeded, try again later", false},
181 {"codex error: unsupported parameter: reasoning", false},
182 }
183 for _, tc := range cases {
184 if got := a.canRetryError(errors.New(tc.msg), 0); got != tc.want {
185 t.Errorf("canRetryError(%q) = %v; want %v", tc.msg, got, tc.want)
186 }
187 }
188}
189
190type partialRetryFakeClient struct {
191 calls int32
192}
193
194func (c *partialRetryFakeClient) Name() string { return "partial-retry-fake" }
195
196func (c *partialRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
197 call := atomic.AddInt32(&c.calls, 1)
198 out := make(chan provider.Event, 4)
199 go func() {
200 defer close(out)
201 out <- provider.EventStart{Provider: "partial-retry-fake", Model: req.Model}
202 if call == 1 {
203 out <- provider.EventTextDelta{Delta: "partial"}
204 out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("provider returned error: 503"), Message: provider.Message{
205 Role: provider.RoleAssistant,
206 Content: []provider.Content{provider.TextBlock{Text: "partial"}},
207 }}
208 return
209 }
210 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
211 Role: provider.RoleAssistant,
212 Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
213 }}
214 }()
215 return out, nil
216}
217
218func TestAgentDropsPartialAssistantBeforeRetry(t *testing.T) {
219 client := &partialRetryFakeClient{}
220 a := NewAgent(client, "fake-model", "system", Registry{})
221 a.RetryBaseDelay = time.Millisecond
222
223 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
224 t.Fatalf("Prompt returned %v", err)
225 }
226 msgs := a.Messages()
227 if len(msgs) != 2 {
228 t.Fatalf("message count = %d; want user + recovered assistant", len(msgs))
229 }
230 if got := extractText(msgs[1]); got != "recovered" {
231 t.Fatalf("final assistant text = %q; want recovered", got)
232 }
233}
234
235// captureClient records the last Request it received so tests can
236// assert what the agent put on the wire.
237type captureClient struct {
238 lastReq provider.Request
239}
240
241func (c *captureClient) Name() string { return "capture" }
242
243func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
244 c.lastReq = req
245 out := make(chan provider.Event, 3)
246 go func() {
247 defer close(out)
248 out <- provider.EventStart{Provider: "capture", Model: req.Model}
249 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
250 Role: provider.RoleAssistant,
251 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
252 }}
253 }()
254 return out, nil
255}
256
257func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) {
258 client := &captureClient{}
259 a := NewAgent(client, "fake-model", "system", Registry{})
260 a.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
261 return true, "", "current phase: parse files"
262 }
263 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
264 t.Fatalf("Prompt returned %v", err)
265 }
266 if !strings.Contains(client.lastReq.System, "current phase: parse files") {
267 t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System)
268 }
269 if len(a.Messages()) != 2 {
270 t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages()))
271 }
272 for _, msg := range a.Messages() {
273 if strings.Contains(extractText(msg), "current phase: parse files") {
274 t.Fatal("hidden turn context leaked into transcript")
275 }
276 }
277}
278
279func TestBoundedTurnContextPreservesUTF8AndLimit(t *testing.T) {
280 contextText := strings.Repeat("界", maxTurnContextBytes)
281 got := boundedTurnContext(contextText)
282 if len(got) > maxTurnContextBytes {
283 t.Fatalf("bounded context bytes = %d, want <= %d", len(got), maxTurnContextBytes)
284 }
285 if !utf8.ValidString(got) {
286 t.Fatal("bounded context is not valid UTF-8")
287 }
288 if !strings.HasSuffix(got, turnContextTruncatedMarker) {
289 tail := got
290 if len(tail) > 64 {
291 tail = tail[len(tail)-64:]
292 }
293 t.Fatalf("bounded context missing truncation marker: %q", tail)
294 }
295}
296
297func TestAgentPropagatesMaxTokens(t *testing.T) {
298 client := &captureClient{}
299 a := NewAgent(client, "fake-model", "system", Registry{})
300 a.MaxTokens = 64000
301
302 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
303 t.Fatalf("Prompt returned %v", err)
304 }
305 if client.lastReq.MaxTokens != 64000 {
306 t.Fatalf("request MaxTokens = %d; want 64000 (Agent.MaxTokens not propagated)", client.lastReq.MaxTokens)
307 }
308}
309
310func TestAgentPropagatesTemperature(t *testing.T) {
311 client := &captureClient{}
312 a := NewAgent(client, "fake-model", "system", Registry{})
313 temp := float32(0)
314 a.Temperature = &temp
315
316 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
317 t.Fatalf("Prompt returned %v", err)
318 }
319 if client.lastReq.Temperature == nil || *client.lastReq.Temperature != temp {
320 t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp)
321 }
322}
323
324type cancelledRetryClient struct {
325 cancel context.CancelFunc
326}
327
328func (c *cancelledRetryClient) Name() string { return "cancelled-retry" }
329
330func (c *cancelledRetryClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
331 out := make(chan provider.Event, 1)
332 c.cancel()
333 out <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider overloaded")}
334 close(out)
335 return out, nil
336}
337
338func TestAgentDoesNotReportRetryAfterCancellation(t *testing.T) {
339 ctx, cancel := context.WithCancel(context.Background())
340 client := &cancelledRetryClient{cancel: cancel}
341 agent := NewAgent(client, "fake-model", "system", Registry{})
342 agent.MaxRetries = 1
343
344 var retries []EvRetryScheduled
345 err := agent.Prompt(ctx, "hello", nil, func(event AgentEvent) {
346 if retry, ok := event.(EvRetryScheduled); ok {
347 retries = append(retries, retry)
348 }
349 })
350 if !errors.Is(err, context.Canceled) {
351 t.Fatalf("Prompt error = %v, want context.Canceled", err)
352 }
353 if len(retries) != 0 {
354 t.Fatalf("retry events = %#v, want none after cancellation", retries)
355 }
356}