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