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