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 call := atomic.AddInt32(&c.calls, 1)
24 out := make(chan provider.Event, 4)
25 go func() {
26 defer close(out)
27 out <- provider.EventStart{Provider: "retry-fake", Model: req.Model}
28 if call == 1 {
29 out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("anthropic overloaded_error: Overloaded")}
30 return
31 }
32 out <- provider.EventTextDelta{Delta: "ok"}
33 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
34 Role: provider.RoleAssistant,
35 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
36 }}
37 }()
38 return out, nil
39}
40
41func TestAgentRetriesOverloadedStreamError(t *testing.T) {
42 client := &retryFakeClient{}
43 a := NewAgent(client, "fake-model", "system", Registry{})
44 a.RetryBaseDelay = time.Millisecond
45
46 var turnErrs []string
47 err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
48 if e, ok := ev.(EvTurnEnd); ok && e.Err != nil {
49 turnErrs = append(turnErrs, e.Err.Error())
50 }
51 })
52 if err != nil {
53 t.Fatalf("Prompt returned %v", err)
54 }
55 if got := atomic.LoadInt32(&client.calls); got != 2 {
56 t.Fatalf("Stream calls = %d; want 2", got)
57 }
58 if len(turnErrs) != 1 || !strings.Contains(turnErrs[0], "overloaded_error") {
59 t.Fatalf("turn errors = %v; want one overloaded error before retry", turnErrs)
60 }
61 msgs := a.Messages()
62 if len(msgs) != 2 {
63 t.Fatalf("message count = %d; want user + final assistant", len(msgs))
64 }
65 if got := extractText(msgs[1]); got != "ok" {
66 t.Fatalf("final assistant text = %q; want ok", got)
67 }
68}
69
70// codexRetryFakeClient reproduces a transient OpenAI Codex backend
71// failure on the first call and succeeds afterwards.
72type codexRetryFakeClient struct {
73 calls int32
74 firstErr string
75}
76
77func (c *codexRetryFakeClient) Name() string { return "openai-codex" }
78
79func (c *codexRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
80 call := atomic.AddInt32(&c.calls, 1)
81 out := make(chan provider.Event, 4)
82 go func() {
83 defer close(out)
84 out <- provider.EventStart{Provider: "openai-codex", Model: req.Model}
85 if call == 1 {
86 out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("%s", c.firstErr)}
87 return
88 }
89 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
90 Role: provider.RoleAssistant,
91 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
92 }}
93 }()
94 return out, nil
95}
96
97func TestAgentRetriesCodexProcessingError(t *testing.T) {
98 cases := []struct {
99 name string
100 err string
101 }{
102 {
103 name: "processing error",
104 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.",
105 },
106 {
107 name: "servers overloaded",
108 err: "codex error: Our servers are currently overloaded. Please try again later.",
109 },
110 {
111 name: "try again later only",
112 err: "codex error: Something went wrong. Please try again later.",
113 },
114 }
115 for _, tc := range cases {
116 t.Run(tc.name, func(t *testing.T) {
117 client := &codexRetryFakeClient{firstErr: tc.err}
118 a := NewAgent(client, "gpt-5.6-sol", "system", Registry{})
119 a.RetryBaseDelay = time.Millisecond
120
121 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
122 t.Fatalf("Prompt returned %v; want retry to succeed", err)
123 }
124 if got := atomic.LoadInt32(&client.calls); got != 2 {
125 t.Fatalf("Stream calls = %d; want 2", got)
126 }
127 msgs := a.Messages()
128 if len(msgs) != 2 || extractText(msgs[1]) != "ok" {
129 t.Fatalf("messages = %v; want user + ok assistant", msgs)
130 }
131 })
132 }
133}
134
135// TestCanRetryErrorCapacityMessages pins the classification of Codex
136// capacity wording and makes sure quota/usage limits stay terminal even
137// when they carry "try again later" style advice.
138func TestCanRetryErrorCapacityMessages(t *testing.T) {
139 a := NewAgent(nil, "gpt-5.6-sol", "system", Registry{})
140 cases := []struct {
141 msg string
142 want bool
143 }{
144 {"codex error: Our servers are currently overloaded. Please try again later.", true},
145 {"codex error: Our servers are busy right now.", true},
146 {"codex error: Please try again later.", true},
147 {"codex error: You have hit your monthly usage limit. Try again later.", false},
148 {"codex error: quota exceeded, try again later", false},
149 {"codex error: unsupported parameter: reasoning", false},
150 }
151 for _, tc := range cases {
152 if got := a.canRetryError(errors.New(tc.msg), 0); got != tc.want {
153 t.Errorf("canRetryError(%q) = %v; want %v", tc.msg, got, tc.want)
154 }
155 }
156}
157
158type partialRetryFakeClient struct {
159 calls int32
160}
161
162func (c *partialRetryFakeClient) Name() string { return "partial-retry-fake" }
163
164func (c *partialRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
165 call := atomic.AddInt32(&c.calls, 1)
166 out := make(chan provider.Event, 4)
167 go func() {
168 defer close(out)
169 out <- provider.EventStart{Provider: "partial-retry-fake", Model: req.Model}
170 if call == 1 {
171 out <- provider.EventTextDelta{Delta: "partial"}
172 out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("provider returned error: 503"), Message: provider.Message{
173 Role: provider.RoleAssistant,
174 Content: []provider.Content{provider.TextBlock{Text: "partial"}},
175 }}
176 return
177 }
178 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
179 Role: provider.RoleAssistant,
180 Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
181 }}
182 }()
183 return out, nil
184}
185
186func TestAgentDropsPartialAssistantBeforeRetry(t *testing.T) {
187 client := &partialRetryFakeClient{}
188 a := NewAgent(client, "fake-model", "system", Registry{})
189 a.RetryBaseDelay = time.Millisecond
190
191 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
192 t.Fatalf("Prompt returned %v", err)
193 }
194 msgs := a.Messages()
195 if len(msgs) != 2 {
196 t.Fatalf("message count = %d; want user + recovered assistant", len(msgs))
197 }
198 if got := extractText(msgs[1]); got != "recovered" {
199 t.Fatalf("final assistant text = %q; want recovered", got)
200 }
201}
202
203// captureClient records the last Request it received so tests can
204// assert what the agent put on the wire.
205type captureClient struct {
206 lastReq provider.Request
207}
208
209func (c *captureClient) Name() string { return "capture" }
210
211func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
212 c.lastReq = req
213 out := make(chan provider.Event, 3)
214 go func() {
215 defer close(out)
216 out <- provider.EventStart{Provider: "capture", Model: req.Model}
217 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
218 Role: provider.RoleAssistant,
219 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
220 }}
221 }()
222 return out, nil
223}
224
225func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) {
226 client := &captureClient{}
227 a := NewAgent(client, "fake-model", "system", Registry{})
228 a.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
229 return true, "", "current phase: parse files"
230 }
231 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
232 t.Fatalf("Prompt returned %v", err)
233 }
234 if !strings.Contains(client.lastReq.System, "current phase: parse files") {
235 t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System)
236 }
237 if len(a.Messages()) != 2 {
238 t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages()))
239 }
240 for _, msg := range a.Messages() {
241 if strings.Contains(extractText(msg), "current phase: parse files") {
242 t.Fatal("hidden turn context leaked into transcript")
243 }
244 }
245}
246
247func TestBoundedTurnContextPreservesUTF8AndLimit(t *testing.T) {
248 contextText := strings.Repeat("界", maxTurnContextBytes)
249 got := boundedTurnContext(contextText)
250 if len(got) > maxTurnContextBytes {
251 t.Fatalf("bounded context bytes = %d, want <= %d", len(got), maxTurnContextBytes)
252 }
253 if !utf8.ValidString(got) {
254 t.Fatal("bounded context is not valid UTF-8")
255 }
256 if !strings.HasSuffix(got, turnContextTruncatedMarker) {
257 tail := got
258 if len(tail) > 64 {
259 tail = tail[len(tail)-64:]
260 }
261 t.Fatalf("bounded context missing truncation marker: %q", tail)
262 }
263}
264
265func TestAgentPropagatesMaxTokens(t *testing.T) {
266 client := &captureClient{}
267 a := NewAgent(client, "fake-model", "system", Registry{})
268 a.MaxTokens = 64000
269
270 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
271 t.Fatalf("Prompt returned %v", err)
272 }
273 if client.lastReq.MaxTokens != 64000 {
274 t.Fatalf("request MaxTokens = %d; want 64000 (Agent.MaxTokens not propagated)", client.lastReq.MaxTokens)
275 }
276}
277
278func TestAgentPropagatesTemperature(t *testing.T) {
279 client := &captureClient{}
280 a := NewAgent(client, "fake-model", "system", Registry{})
281 temp := float32(0)
282 a.Temperature = &temp
283
284 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
285 t.Fatalf("Prompt returned %v", err)
286 }
287 if client.lastReq.Temperature == nil || *client.lastReq.Temperature != temp {
288 t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp)
289 }
290}