1package core
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "reflect"
10 "strings"
11 "sync/atomic"
12 "testing"
13 "time"
14 "unicode/utf8"
15
16 "github.com/bnema/zut/packages/provider"
17)
18
19type retryFakeClient struct {
20 calls int32
21 firstErr error
22}
23
24func (c *retryFakeClient) Name() string { return "retry-fake" }
25
26func (c *retryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
27 if req.Lifecycle != nil {
28 req.Lifecycle.RequestAttempt(1, 1)
29 }
30 call := atomic.AddInt32(&c.calls, 1)
31 out := make(chan provider.Event, 4)
32 go func() {
33 defer close(out)
34 out <- provider.EventStart{Provider: "retry-fake", Model: req.Model}
35 if call == 1 {
36 err := c.firstErr
37 if err == nil {
38 err = fmt.Errorf("anthropic overloaded_error: Overloaded")
39 }
40 out <- provider.EventDone{Stop: provider.StopError, Err: err}
41 return
42 }
43 out <- provider.EventTextDelta{Delta: "ok"}
44 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
45 Role: provider.RoleAssistant,
46 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
47 }}
48 }()
49 return out, nil
50}
51
52type silentStreamClient struct{}
53
54func (silentStreamClient) Name() string { return "silent-stream" }
55
56func (silentStreamClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
57 out := make(chan provider.Event)
58 go func() {
59 defer close(out)
60 <-ctx.Done()
61 }()
62 return out, nil
63}
64
65type activeStreamClient struct{}
66
67func (activeStreamClient) Name() string { return "active-stream" }
68
69func (activeStreamClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) {
70 out := make(chan provider.Event, 2)
71 out <- provider.EventStart{}
72 out <- provider.EventDone{Stop: provider.StopEnd}
73 close(out)
74 return out, nil
75}
76
77type postToolSilentClient struct {
78 calls atomic.Int32
79}
80
81func (c *postToolSilentClient) Name() string { return "post-tool-silent" }
82
83func (c *postToolSilentClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
84 if c.calls.Add(1) == 1 {
85 out := make(chan provider.Event, 1)
86 out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
87 Role: provider.RoleAssistant,
88 Content: []provider.Content{provider.ToolCallBlock{
89 ID: "call-1", Name: "result", Arguments: json.RawMessage(`{}`),
90 }},
91 }}
92 close(out)
93 return out, nil
94 }
95 out := make(chan provider.Event)
96 go func() {
97 defer close(out)
98 <-ctx.Done()
99 }()
100 return out, nil
101}
102
103type requestOpenFailureClient struct{ calls int32 }
104
105func (c *requestOpenFailureClient) Name() string { return "request-open-failure" }
106
107func (c *requestOpenFailureClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) {
108 atomic.AddInt32(&c.calls, 1)
109 return nil, errors.New("provider returned error: 503 service unavailable")
110}
111
112type readableAfterCancellationClient struct {
113 cancel context.CancelFunc
114 drained chan struct{}
115}
116
117func (c readableAfterCancellationClient) Name() string { return "readable-after-cancellation" }
118
119func (c readableAfterCancellationClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
120 out := make(chan provider.Event, 1)
121 out <- provider.EventStart{}
122 c.cancel()
123 go func() {
124 out <- provider.EventTextDelta{Delta: "discard after cancellation"}
125 close(out)
126 close(c.drained)
127 }()
128 return out, nil
129}
130
131func TestNewAgentUsesCodexStreamGuardDefaults(t *testing.T) {
132 a := NewAgent(&retryFakeClient{}, "fake-model", "system", Registry{})
133 if a.MaxSteps != 0 {
134 t.Fatalf("max steps = %d, want unlimited", a.MaxSteps)
135 }
136 if a.MaxRetries != 5 {
137 t.Fatalf("stream retries = %d, want 5", a.MaxRetries)
138 }
139 if a.StreamIdleTimeout != 5*time.Minute {
140 t.Fatalf("stream idle timeout = %s, want 5m", a.StreamIdleTimeout)
141 }
142}
143
144func TestAgentStopsReadingStreamAfterCancellation(t *testing.T) {
145 ctx, cancel := context.WithCancel(context.Background())
146 drained := make(chan struct{})
147 a := NewAgent(readableAfterCancellationClient{cancel: cancel, drained: drained}, "fake-model", "system", Registry{})
148 a.MaxRetries = 0
149 if err := a.Prompt(ctx, "hello", nil, nil); !errors.Is(err, context.Canceled) {
150 t.Fatalf("Prompt error = %v, want context.Canceled", err)
151 }
152 select {
153 case <-drained:
154 case <-time.After(time.Second):
155 t.Fatal("provider producer remained blocked after turn cancellation")
156 }
157}
158
159func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {
160 a := NewAgent(silentStreamClient{}, "fake-model", "system", Registry{})
161 a.MaxRetries = 0
162 a.StreamIdleTimeout = time.Minute
163 idle := make(chan time.Time, 1)
164 a.streamIdleTimer = func(time.Duration) (<-chan time.Time, func(), func()) {
165 idle <- time.Now()
166 return idle, func() {}, func() {}
167 }
168 if err := a.Prompt(context.Background(), "hello", nil, nil); !errors.Is(err, ErrStreamIdleTimeout) {
169 t.Fatalf("Prompt error = %v, want ErrStreamIdleTimeout", err)
170 }
171}
172
173func TestAgentResetsIdleDeadlineForEveryStreamEvent(t *testing.T) {
174 a := NewAgent(activeStreamClient{}, "fake-model", "system", Registry{})
175 a.MaxRetries = 0
176 a.StreamIdleTimeout = time.Minute
177 var resets atomic.Int32
178 a.streamIdleTimer = func(time.Duration) (<-chan time.Time, func(), func()) {
179 return make(chan time.Time), func() {}, func() { resets.Add(1) }
180 }
181 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
182 t.Fatal(err)
183 }
184 if got := resets.Load(); got != 2 {
185 t.Fatalf("idle timer resets = %d, want one for each stream event", got)
186 }
187}
188
189func TestAgentDoesNotRetrySilentStream(t *testing.T) {
190 client := &retryFakeClient{firstErr: ErrStreamIdleTimeout}
191 a := NewAgent(client, "fake-model", "system", Registry{})
192 a.RetryBaseDelay = time.Millisecond
193 if err := a.Prompt(context.Background(), "hello", nil, nil); !errors.Is(err, ErrStreamIdleTimeout) {
194 t.Fatalf("Prompt error = %v, want ErrStreamIdleTimeout", err)
195 }
196 if got := atomic.LoadInt32(&client.calls); got != 1 {
197 t.Fatalf("Stream calls = %d; want no reconnect after idle deadline", got)
198 }
199}
200
201func TestAgentFailsSilentContinuationAfterToolResult(t *testing.T) {
202 client := &postToolSilentClient{}
203 a := NewAgent(client, "fake-model", "system", Registry{
204 "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}},
205 })
206 a.RetryBaseDelay = time.Millisecond
207 var timers atomic.Int32
208 a.streamIdleTimer = func(time.Duration) (<-chan time.Time, func(), func()) {
209 idle := make(chan time.Time, 1)
210 if timers.Add(1) > 1 {
211 idle <- time.Now()
212 }
213 return idle, func() {}, func() {}
214 }
215 var durable []string
216 err := a.Prompt(context.Background(), "review", nil, func(event AgentEvent) {
217 switch event.(type) {
218 case EvUserMessage, EvAssistantMessage, EvToolCall, EvToolResult:
219 durable = append(durable, event.Type())
220 }
221 })
222 if !errors.Is(err, ErrStreamIdleTimeout) {
223 t.Fatalf("Prompt error = %v, want ErrStreamIdleTimeout", err)
224 }
225 if got := client.calls.Load(); got != 2 {
226 t.Fatalf("provider calls = %d, want tool request plus one bounded continuation", got)
227 }
228 if len(durable) == 0 {
229 t.Fatal("agent emitted no durable events")
230 }
231 if got, want := durable[len(durable)-1], "tool_result"; got != want {
232 t.Fatalf("last durable event = %q, want %q; events = %v", got, want, durable)
233 }
234}
235
236func TestAgentRetriesOverloadedStreamError(t *testing.T) {
237 client := &retryFakeClient{}
238 a := NewAgent(client, "fake-model", "system", Registry{})
239 a.RetryBaseDelay = time.Millisecond
240
241 var turnErrs []string
242 err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
243 if e, ok := ev.(EvTurnEnd); ok && e.Err != nil {
244 turnErrs = append(turnErrs, e.Err.Error())
245 }
246 })
247 if err != nil {
248 t.Fatalf("Prompt returned %v", err)
249 }
250 if got := atomic.LoadInt32(&client.calls); got != 2 {
251 t.Fatalf("Stream calls = %d; want 2", got)
252 }
253 if len(turnErrs) != 1 || !strings.Contains(turnErrs[0], "overloaded_error") {
254 t.Fatalf("turn errors = %v; want one overloaded error before retry", turnErrs)
255 }
256 msgs := a.Messages()
257 if len(msgs) != 2 {
258 t.Fatalf("message count = %d; want user + final assistant", len(msgs))
259 }
260 if got := extractText(msgs[1]); got != "ok" {
261 t.Fatalf("final assistant text = %q; want ok", got)
262 }
263}
264
265func TestAgentDoesNotReplayProviderRequestRetriesAtStreamLayer(t *testing.T) {
266 client := &requestOpenFailureClient{}
267 a := NewAgent(client, "fake-model", "system", Registry{})
268 a.RetryBaseDelay = time.Millisecond
269
270 err := a.Prompt(context.Background(), "hello", nil, nil)
271 if err == nil || !strings.Contains(err.Error(), "503") {
272 t.Fatalf("Prompt error = %v, want provider failure", err)
273 }
274 if got := atomic.LoadInt32(&client.calls); got != 1 {
275 t.Fatalf("Stream calls = %d, want one provider-owned request sequence", got)
276 }
277}
278
279func TestAgentRetriesUnexpectedEOFStreamError(t *testing.T) {
280 client := &retryFakeClient{firstErr: fmt.Errorf("read SSE: %w", io.ErrUnexpectedEOF)}
281 a := NewAgent(client, "fake-model", "system", Registry{})
282 a.RetryBaseDelay = time.Millisecond
283
284 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
285 t.Fatalf("Prompt returned %v", err)
286 }
287 if got := atomic.LoadInt32(&client.calls); got != 2 {
288 t.Fatalf("Stream calls = %d; want 2", got)
289 }
290}
291
292func TestAgentRetriesBareEOFStreamError(t *testing.T) {
293 client := &retryFakeClient{firstErr: fmt.Errorf("read SSE: %w", io.EOF)}
294 a := NewAgent(client, "fake-model", "system", Registry{})
295 a.RetryBaseDelay = time.Millisecond
296 var records []RetryLifecycleRecord
297 a.OnRetryLifecycle = func(record RetryLifecycleRecord) {
298 records = append(records, record)
299 }
300
301 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
302 t.Fatalf("Prompt returned %v", err)
303 }
304 if got := atomic.LoadInt32(&client.calls); got != 2 {
305 t.Fatalf("Stream calls = %d; want 2", got)
306 }
307 if len(records) != 2 || records[0].Reason != RetryReasonNetwork || records[1].Reason != RetryReasonNetwork {
308 t.Fatalf("retry lifecycle = %#v; want network failure and retry", records)
309 }
310}
311
312func TestAgentEmitsRetryLifecycleEvents(t *testing.T) {
313 client := &retryFakeClient{}
314 a := NewAgent(client, "fake-model", "system", Registry{})
315 a.RetryBaseDelay = time.Millisecond
316
317 var lifecycle []string
318 if err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
319 switch e := ev.(type) {
320 case EvRequestStarted:
321 lifecycle = append(lifecycle, fmt.Sprintf("request:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
322 case EvRetryScheduled:
323 lifecycle = append(lifecycle, fmt.Sprintf("retry:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
324 }
325 }); err != nil {
326 t.Fatalf("Prompt returned %v", err)
327 }
328
329 want := []string{
330 "request:agent:1/6",
331 "request:provider:1/1",
332 "retry:agent:2/6",
333 "request:agent:2/6",
334 "request:provider:1/1",
335 }
336 if strings.Join(lifecycle, ",") != strings.Join(want, ",") {
337 t.Fatalf("lifecycle = %v, want %v", lifecycle, want)
338 }
339}
340
341func TestAgentReportsSanitizedRetryLifecycle(t *testing.T) {
342 client := &retryFakeClient{}
343 a := NewAgent(client, "fake-model", "system", Registry{})
344 a.RetryBaseDelay = time.Millisecond
345
346 var records []RetryLifecycleRecord
347 a.OnRetryLifecycle = func(record RetryLifecycleRecord) {
348 records = append(records, record)
349 }
350 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
351 t.Fatalf("Prompt returned %v", err)
352 }
353
354 want := []RetryLifecycleRecord{
355 {
356 Event: RetryLifecycleRequestFailed, Scope: RetryScopeAgent,
357 Attempt: 1, MaxAttempts: 6, Reason: RetryReasonOverload,
358 },
359 {
360 Event: RetryLifecycleRetryScheduled, Scope: RetryScopeAgent,
361 Attempt: 2, MaxAttempts: 6, Reason: RetryReasonOverload, DelayMS: 1,
362 },
363 }
364 if !reflect.DeepEqual(records, want) {
365 t.Fatalf("retry lifecycle = %#v, want %#v", records, want)
366 }
367}
368
369func TestRequestLifecycleSinkForwardsProviderFailureReason(t *testing.T) {
370 var records []RetryLifecycleRecord
371 sink := &requestLifecycleSink{
372 sink: func(AgentEvent) {},
373 observe: func(record RetryLifecycleRecord) { records = append(records, record) },
374 }
375 sink.RequestFailed(1, 3, provider.RequestFailureOverload, false)
376 sink.RetryScheduled(2, 3, 250*time.Millisecond)
377
378 want := []RetryLifecycleRecord{
379 {
380 Event: RetryLifecycleRequestFailed, Scope: RetryScopeProvider,
381 Attempt: 1, MaxAttempts: 3, Reason: RetryReasonOverload,
382 },
383 {
384 Event: RetryLifecycleRetryScheduled, Scope: RetryScopeProvider,
385 Attempt: 2, MaxAttempts: 3, Reason: RetryReasonOverload, DelayMS: 250,
386 },
387 }
388 if !reflect.DeepEqual(records, want) {
389 t.Fatalf("provider retry lifecycle = %#v, want %#v", records, want)
390 }
391}
392
393func TestAgentReportsTerminalRetryFailure(t *testing.T) {
394 client := &retryFakeClient{}
395 a := NewAgent(client, "fake-model", "system", Registry{})
396 a.MaxRetries = 0
397
398 var records []RetryLifecycleRecord
399 a.OnRetryLifecycle = func(record RetryLifecycleRecord) {
400 records = append(records, record)
401 }
402 if err := a.Prompt(context.Background(), "hello", nil, nil); err == nil {
403 t.Fatal("Prompt succeeded; want terminal provider error")
404 }
405 if len(records) != 1 {
406 t.Fatalf("retry lifecycle = %#v, want one terminal record", records)
407 }
408 record := records[0]
409 if record.Event != RetryLifecycleRequestFailed || record.Scope != RetryScopeAgent ||
410 record.Attempt != 1 || record.MaxAttempts != 1 || record.Reason != RetryReasonOverload || !record.Terminal {
411 t.Fatalf("terminal retry record = %#v", record)
412 }
413}
414
415// codexRetryFakeClient reproduces a transient OpenAI Codex backend
416// failure on the first call and succeeds afterwards.
417type codexRetryFakeClient struct {
418 calls int32
419 firstErr string
420}
421
422func (c *codexRetryFakeClient) Name() string { return "openai-codex" }
423
424func (c *codexRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
425 call := atomic.AddInt32(&c.calls, 1)
426 out := make(chan provider.Event, 4)
427 go func() {
428 defer close(out)
429 out <- provider.EventStart{Provider: "openai-codex", Model: req.Model}
430 if call == 1 {
431 out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("%s", c.firstErr)}
432 return
433 }
434 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
435 Role: provider.RoleAssistant,
436 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
437 }}
438 }()
439 return out, nil
440}
441
442func TestAgentRetriesCodexProcessingError(t *testing.T) {
443 cases := []struct {
444 name string
445 err string
446 }{
447 {
448 name: "processing error",
449 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.",
450 },
451 {
452 name: "servers overloaded",
453 err: "codex error: Our servers are currently overloaded. Please try again later.",
454 },
455 {
456 name: "try again later only",
457 err: "codex error: Something went wrong. Please try again later.",
458 },
459 }
460 for _, tc := range cases {
461 t.Run(tc.name, func(t *testing.T) {
462 client := &codexRetryFakeClient{firstErr: tc.err}
463 a := NewAgent(client, "gpt-5.6-sol", "system", Registry{})
464 a.RetryBaseDelay = time.Millisecond
465
466 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
467 t.Fatalf("Prompt returned %v; want retry to succeed", err)
468 }
469 if got := atomic.LoadInt32(&client.calls); got != 2 {
470 t.Fatalf("Stream calls = %d; want 2", got)
471 }
472 msgs := a.Messages()
473 if len(msgs) != 2 || extractText(msgs[1]) != "ok" {
474 t.Fatalf("messages = %v; want user + ok assistant", msgs)
475 }
476 })
477 }
478}
479
480// TestCanRetryErrorCapacityMessages pins the classification of Codex
481// capacity wording and makes sure quota/usage limits stay terminal even
482// when they carry "try again later" style advice.
483func TestCanRetryErrorCapacityMessages(t *testing.T) {
484 a := NewAgent(nil, "gpt-5.6-sol", "system", Registry{})
485 cases := []struct {
486 msg string
487 want bool
488 }{
489 {"codex error: Our servers are currently overloaded. Please try again later.", true},
490 {"codex error: Our servers are busy right now.", true},
491 {"codex error: Please try again later.", true},
492 {"codex error: You have hit your monthly usage limit. Try again later.", false},
493 {"codex error: quota exceeded, try again later", false},
494 {"codex error: unsupported parameter: reasoning", false},
495 }
496 for _, tc := range cases {
497 if got := a.canRetryError(errors.New(tc.msg), 0); got != tc.want {
498 t.Errorf("canRetryError(%q) = %v; want %v", tc.msg, got, tc.want)
499 }
500 }
501}
502
503type partialRetryFakeClient struct {
504 calls int32
505}
506
507func (c *partialRetryFakeClient) Name() string { return "partial-retry-fake" }
508
509func (c *partialRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
510 call := atomic.AddInt32(&c.calls, 1)
511 out := make(chan provider.Event, 4)
512 go func() {
513 defer close(out)
514 out <- provider.EventStart{Provider: "partial-retry-fake", Model: req.Model}
515 if call == 1 {
516 out <- provider.EventTextDelta{Delta: "partial"}
517 out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("provider returned error: 503"), Message: provider.Message{
518 Role: provider.RoleAssistant,
519 Content: []provider.Content{provider.TextBlock{Text: "partial"}},
520 }}
521 return
522 }
523 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
524 Role: provider.RoleAssistant,
525 Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
526 }}
527 }()
528 return out, nil
529}
530
531func TestAgentDropsPartialAssistantBeforeRetry(t *testing.T) {
532 client := &partialRetryFakeClient{}
533 a := NewAgent(client, "fake-model", "system", Registry{})
534 a.RetryBaseDelay = time.Millisecond
535
536 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
537 t.Fatalf("Prompt returned %v", err)
538 }
539 msgs := a.Messages()
540 if len(msgs) != 2 {
541 t.Fatalf("message count = %d; want user + recovered assistant", len(msgs))
542 }
543 if got := extractText(msgs[1]); got != "recovered" {
544 t.Fatalf("final assistant text = %q; want recovered", got)
545 }
546}
547
548// captureClient records the last Request it received so tests can
549// assert what the agent put on the wire.
550type captureClient struct {
551 lastReq provider.Request
552}
553
554func (c *captureClient) Name() string { return "capture" }
555
556func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
557 c.lastReq = req
558 out := make(chan provider.Event, 3)
559 go func() {
560 defer close(out)
561 out <- provider.EventStart{Provider: "capture", Model: req.Model}
562 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
563 Role: provider.RoleAssistant,
564 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
565 }}
566 }()
567 return out, nil
568}
569
570func TestAgentPersistsTurnContextAsDeveloperHistory(t *testing.T) {
571 client := &captureClient{}
572 a := NewAgent(client, "fake-model", "system", Registry{})
573 a.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
574 return true, "", "current phase: parse files"
575 }
576 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
577 t.Fatalf("Prompt returned %v", err)
578 }
579 if strings.Contains(client.lastReq.System, "current phase: parse files") {
580 t.Fatalf("stable request system prompt = %q; dynamic context must not rewrite it", client.lastReq.System)
581 }
582 if len(client.lastReq.Messages) != 2 || client.lastReq.Messages[0].Role != provider.RoleDeveloper || !strings.Contains(extractText(client.lastReq.Messages[0]), "current phase: parse files") {
583 t.Fatalf("request context history = %#v, want developer context before user task", client.lastReq.Messages)
584 }
585 if len(a.Messages()) != 3 || a.Messages()[0].Role != provider.RoleDeveloper {
586 t.Fatalf("persisted transcript = %#v, want developer context", a.Messages())
587 }
588}
589
590func TestBoundedTurnContextPreservesUTF8AndLimit(t *testing.T) {
591 contextText := strings.Repeat("界", maxTurnContextBytes)
592 got := boundedTurnContext(contextText)
593 if len(got) > maxTurnContextBytes {
594 t.Fatalf("bounded context bytes = %d, want <= %d", len(got), maxTurnContextBytes)
595 }
596 if !utf8.ValidString(got) {
597 t.Fatal("bounded context is not valid UTF-8")
598 }
599 if !strings.HasSuffix(got, turnContextTruncatedMarker) {
600 tail := got
601 if len(tail) > 64 {
602 tail = tail[len(tail)-64:]
603 }
604 t.Fatalf("bounded context missing truncation marker: %q", tail)
605 }
606}
607
608func TestAgentPropagatesMaxTokens(t *testing.T) {
609 client := &captureClient{}
610 a := NewAgent(client, "fake-model", "system", Registry{})
611 a.MaxTokens = 64000
612
613 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
614 t.Fatalf("Prompt returned %v", err)
615 }
616 if client.lastReq.MaxTokens != 64000 {
617 t.Fatalf("request MaxTokens = %d; want 64000 (Agent.MaxTokens not propagated)", client.lastReq.MaxTokens)
618 }
619}
620
621func TestAgentPropagatesTemperature(t *testing.T) {
622 client := &captureClient{}
623 a := NewAgent(client, "fake-model", "system", Registry{})
624 temp := float32(0)
625 a.Temperature = &temp
626
627 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
628 t.Fatalf("Prompt returned %v", err)
629 }
630 if client.lastReq.Temperature == nil || *client.lastReq.Temperature != temp {
631 t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp)
632 }
633}
634
635type cancelledRetryClient struct {
636 cancel context.CancelFunc
637}
638
639func (c *cancelledRetryClient) Name() string { return "cancelled-retry" }
640
641func (c *cancelledRetryClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
642 out := make(chan provider.Event, 1)
643 c.cancel()
644 out <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider overloaded")}
645 close(out)
646 return out, nil
647}
648
649func TestAgentDoesNotReportRetryAfterCancellation(t *testing.T) {
650 ctx, cancel := context.WithCancel(context.Background())
651 client := &cancelledRetryClient{cancel: cancel}
652 agent := NewAgent(client, "fake-model", "system", Registry{})
653 agent.MaxRetries = 1
654
655 var retries []EvRetryScheduled
656 var lifecycle []RetryLifecycleRecord
657 agent.OnRetryLifecycle = func(record RetryLifecycleRecord) {
658 lifecycle = append(lifecycle, record)
659 }
660 err := agent.Prompt(ctx, "hello", nil, func(event AgentEvent) {
661 if retry, ok := event.(EvRetryScheduled); ok {
662 retries = append(retries, retry)
663 }
664 })
665 if !errors.Is(err, context.Canceled) {
666 t.Fatalf("Prompt error = %v, want context.Canceled", err)
667 }
668 if len(retries) != 0 {
669 t.Fatalf("retry events = %#v, want none after cancellation", retries)
670 }
671 if len(lifecycle) != 1 || lifecycle[0].Event != RetryLifecycleRequestFailed || !lifecycle[0].Terminal {
672 t.Fatalf("retry lifecycle = %#v, want one terminal failure after cancellation", lifecycle)
673 }
674}