/*
 * Copyright 2026 CloudWeGo Authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package adk

import (
	"context"
	"errors"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/cloudwego/eino/components/model"
	"github.com/cloudwego/eino/schema"
)

func newFakeChatModel(
	gen func(context.Context, []*schema.Message, ...model.Option) (*schema.Message, error),
	stream func(context.Context, []*schema.Message, ...model.Option) (*schema.StreamReader[*schema.Message], error),
) *fakeChatModel {
	if gen == nil {
		gen = func(context.Context, []*schema.Message, ...model.Option) (*schema.Message, error) {
			return nil, errors.New("unused")
		}
	}
	if stream == nil {
		stream = func(context.Context, []*schema.Message, ...model.Option) (*schema.StreamReader[*schema.Message], error) {
			return nil, errors.New("unused")
		}
	}
	return &fakeChatModel{callbacksEnabled: true, generate: gen, stream: stream}
}

func TestRetryThenFailover(t *testing.T) {
	t.Run("Generate_RetryExhaustedTriggersFailover", func(t *testing.T) {
		modelErr := errors.New("model error")
		var m1Calls int32
		var m2Calls int32

		m1 := newFakeChatModel(func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
			atomic.AddInt32(&m1Calls, 1)
			return nil, modelErr
		}, nil)
		m2 := newFakeChatModel(func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
			atomic.AddInt32(&m2Calls, 1)
			return schema.AssistantMessage("ok from m2", nil), nil
		}, nil)

		retryCfg := &ModelRetryConfig{
			MaxRetries:  2,
			IsRetryAble: func(_ context.Context, err error) bool { return true },
			BackoffFunc: func(_ context.Context, _ int) time.Duration { return 0 },
		}

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 1,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				return err != nil
			},
			GetFailoverModel: func(_ context.Context, fc *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				require.NotNil(t, fc.LastErr)
				return m2, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			retryConfig:    retryCfg,
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		msg, err := wrapped.Generate(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.NoError(t, err)
		require.Equal(t, "ok from m2", msg.Content)

		// m1: 1 (lastSuccess) + 2 retries = 3 calls on lastSuccess attempt,
		// then failover to m2 which also goes through retry wrapper: 1 call succeeds.
		require.Equal(t, int32(3), atomic.LoadInt32(&m1Calls))
		require.Equal(t, int32(1), atomic.LoadInt32(&m2Calls))
	})

	t.Run("Generate_AllExhausted", func(t *testing.T) {
		modelErr := errors.New("always fails")
		var m1Calls int32
		var m2Calls int32

		m1 := newFakeChatModel(func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
			atomic.AddInt32(&m1Calls, 1)
			return nil, modelErr
		}, nil)
		m2 := newFakeChatModel(func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
			atomic.AddInt32(&m2Calls, 1)
			return nil, modelErr
		}, nil)

		retryCfg := &ModelRetryConfig{
			MaxRetries:  1,
			IsRetryAble: func(_ context.Context, err error) bool { return true },
			BackoffFunc: func(_ context.Context, _ int) time.Duration { return 0 },
		}

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 1,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				return err != nil
			},
			GetFailoverModel: func(_ context.Context, _ *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				return m2, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			retryConfig:    retryCfg,
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		_, err := wrapped.Generate(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.Error(t, err)

		// Should be RetryExhaustedError from m2's retry wrapper
		var retryErr *RetryExhaustedError
		require.True(t, errors.As(err, &retryErr))

		// m1: 1 initial + 1 retry = 2 calls
		require.Equal(t, int32(2), atomic.LoadInt32(&m1Calls))
		// m2: 1 initial + 1 retry = 2 calls
		require.Equal(t, int32(2), atomic.LoadInt32(&m2Calls))
	})

	t.Run("Generate_RetrySucceedsNoFailover", func(t *testing.T) {
		var m1Calls int32
		var failoverCalled int32

		m1 := newFakeChatModel(func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
			n := atomic.AddInt32(&m1Calls, 1)
			if n == 1 {
				return nil, errors.New("transient error")
			}
			return schema.AssistantMessage("ok on retry", nil), nil
		}, nil)

		retryCfg := &ModelRetryConfig{
			MaxRetries:  2,
			IsRetryAble: func(_ context.Context, err error) bool { return true },
			BackoffFunc: func(_ context.Context, _ int) time.Duration { return 0 },
		}

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 1,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				atomic.AddInt32(&failoverCalled, 1)
				return true
			},
			GetFailoverModel: func(_ context.Context, _ *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				t.Fatal("GetFailoverModel should not be called when retry succeeds")
				return nil, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			retryConfig:    retryCfg,
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		msg, err := wrapped.Generate(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.NoError(t, err)
		require.Equal(t, "ok on retry", msg.Content)

		// 2 calls: first fails, second succeeds via retry
		require.Equal(t, int32(2), atomic.LoadInt32(&m1Calls))
		// ShouldFailover should never be called
		require.Equal(t, int32(0), atomic.LoadInt32(&failoverCalled))
	})

	t.Run("Generate_NonRetryableErrorTriggersFailover", func(t *testing.T) {
		nonRetryableErr := errors.New("non-retryable")
		var m1Calls int32
		var m2Calls int32

		m1 := newFakeChatModel(func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
			atomic.AddInt32(&m1Calls, 1)
			return nil, nonRetryableErr
		}, nil)
		m2 := newFakeChatModel(func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
			atomic.AddInt32(&m2Calls, 1)
			return schema.AssistantMessage("ok from m2", nil), nil
		}, nil)

		retryCfg := &ModelRetryConfig{
			MaxRetries: 3,
			IsRetryAble: func(_ context.Context, err error) bool {
				// Only non-retryable errors
				return !errors.Is(err, nonRetryableErr)
			},
			BackoffFunc: func(_ context.Context, _ int) time.Duration { return 0 },
		}

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 1,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				return err != nil
			},
			GetFailoverModel: func(_ context.Context, _ *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				return m2, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			retryConfig:    retryCfg,
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		msg, err := wrapped.Generate(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.NoError(t, err)
		require.Equal(t, "ok from m2", msg.Content)

		// m1 called only once — non-retryable error skips retry
		require.Equal(t, int32(1), atomic.LoadInt32(&m1Calls))
		require.Equal(t, int32(1), atomic.LoadInt32(&m2Calls))
	})

	t.Run("Stream_RetryExhaustedTriggersFailover", func(t *testing.T) {
		streamErr := errors.New("stream mid error")
		var m1Calls int32
		var m2Calls int32

		m1 := newFakeChatModel(nil, func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
			atomic.AddInt32(&m1Calls, 1)
			return streamWithMidError([]*schema.Message{
				schema.AssistantMessage("partial", nil),
			}, streamErr), nil
		})
		m2 := newFakeChatModel(nil, func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
			atomic.AddInt32(&m2Calls, 1)
			return schema.StreamReaderFromArray([]*schema.Message{schema.AssistantMessage("ok from m2", nil)}), nil
		})

		retryCfg := &ModelRetryConfig{
			MaxRetries:  1,
			IsRetryAble: func(_ context.Context, err error) bool { return true },
			BackoffFunc: func(_ context.Context, _ int) time.Duration { return 0 },
		}

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 1,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				return err != nil
			},
			GetFailoverModel: func(_ context.Context, fc *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				require.NotNil(t, fc.LastErr)
				return m2, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			retryConfig:    retryCfg,
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		sr, err := wrapped.Stream(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.NoError(t, err)
		msgs, err := drainMessageStream(sr)
		require.NoError(t, err)
		require.Len(t, msgs, 1)
		require.Equal(t, "ok from m2", msgs[0].Content)

		// m1: 1 initial + 1 retry = 2 calls on lastSuccess attempt
		require.Equal(t, int32(2), atomic.LoadInt32(&m1Calls))
		require.Equal(t, int32(1), atomic.LoadInt32(&m2Calls))
	})

	t.Run("Stream_AllExhausted", func(t *testing.T) {
		streamErr := errors.New("always fails mid-stream")
		var m1Calls int32
		var m2Calls int32

		m1 := newFakeChatModel(nil, func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
			atomic.AddInt32(&m1Calls, 1)
			return streamWithMidError([]*schema.Message{
				schema.AssistantMessage("p", nil),
			}, streamErr), nil
		})
		m2 := newFakeChatModel(nil, func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
			atomic.AddInt32(&m2Calls, 1)
			return streamWithMidError([]*schema.Message{
				schema.AssistantMessage("p", nil),
			}, streamErr), nil
		})

		retryCfg := &ModelRetryConfig{
			MaxRetries:  1,
			IsRetryAble: func(_ context.Context, err error) bool { return true },
			BackoffFunc: func(_ context.Context, _ int) time.Duration { return 0 },
		}

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 1,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				return err != nil
			},
			GetFailoverModel: func(_ context.Context, _ *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				return m2, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			retryConfig:    retryCfg,
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		_, err := wrapped.Stream(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.Error(t, err)

		var retryErr *RetryExhaustedError
		require.True(t, errors.As(err, &retryErr))

		// m1: 1 initial + 1 retry = 2 calls
		require.Equal(t, int32(2), atomic.LoadInt32(&m1Calls))
		// m2: 1 initial + 1 retry = 2 calls
		require.Equal(t, int32(2), atomic.LoadInt32(&m2Calls))
	})

	t.Run("ShouldRetry_Stream_TriggersFailover", func(t *testing.T) {
		var m1Calls int32
		var m2Calls int32

		m1 := newFakeChatModel(nil, func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
			atomic.AddInt32(&m1Calls, 1)
			return schema.StreamReaderFromArray([]*schema.Message{schema.AssistantMessage("bad from m1", nil)}), nil
		})
		m2 := newFakeChatModel(nil, func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
			atomic.AddInt32(&m2Calls, 1)
			return schema.StreamReaderFromArray([]*schema.Message{schema.AssistantMessage("good from m2", nil)}), nil
		})

		retryCfg := &ModelRetryConfig{
			MaxRetries: 1,
			ShouldRetry: func(_ context.Context, retryCtx *RetryContext) *RetryDecision {
				if retryCtx.OutputMessage != nil && retryCtx.OutputMessage.Content == "bad from m1" {
					return &RetryDecision{Retry: true}
				}
				return &RetryDecision{Retry: false}
			},
			BackoffFunc: func(_ context.Context, _ int) time.Duration { return 0 },
		}

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 1,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				return err != nil
			},
			GetFailoverModel: func(_ context.Context, _ *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				return m2, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			retryConfig:    retryCfg,
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		sr, err := wrapped.Stream(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.NoError(t, err)
		msgs, err := drainMessageStream(sr)
		require.NoError(t, err)
		require.Len(t, msgs, 1)
		require.Equal(t, "good from m2", msgs[0].Content)
		require.Equal(t, int32(2), atomic.LoadInt32(&m1Calls))
		require.Equal(t, int32(1), atomic.LoadInt32(&m2Calls))
	})

	t.Run("ShouldRetry_Generate_TriggersFailover", func(t *testing.T) {
		var m1Calls int32
		var m2Calls int32

		m1 := newFakeChatModel(func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
			atomic.AddInt32(&m1Calls, 1)
			return schema.AssistantMessage("bad from m1", nil), nil
		}, nil)
		m2 := newFakeChatModel(func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
			atomic.AddInt32(&m2Calls, 1)
			return schema.AssistantMessage("good from m2", nil), nil
		}, nil)

		retryCfg := &ModelRetryConfig{
			MaxRetries: 1,
			ShouldRetry: func(_ context.Context, retryCtx *RetryContext) *RetryDecision {
				if retryCtx.OutputMessage != nil && retryCtx.OutputMessage.Content == "bad from m1" {
					return &RetryDecision{Retry: true}
				}
				return &RetryDecision{Retry: false}
			},
			BackoffFunc: func(_ context.Context, _ int) time.Duration { return 0 },
		}

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 1,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				return err != nil
			},
			GetFailoverModel: func(_ context.Context, _ *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				return m2, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			retryConfig:    retryCfg,
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		msg, err := wrapped.Generate(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.NoError(t, err)
		require.Equal(t, "good from m2", msg.Content)
		require.Equal(t, int32(2), atomic.LoadInt32(&m1Calls))
		require.Equal(t, int32(1), atomic.LoadInt32(&m2Calls))
	})

	t.Run("Stream_GetFailoverModelReturnsNilModel", func(t *testing.T) {
		streamErr := errors.New("m1 always fails")
		var m1Calls int32

		m1 := newFakeChatModel(nil, func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
			atomic.AddInt32(&m1Calls, 1)
			return nil, streamErr
		})

		retryCfg := &ModelRetryConfig{
			MaxRetries:  0,
			IsRetryAble: func(_ context.Context, err error) bool { return false },
			BackoffFunc: func(_ context.Context, _ int) time.Duration { return 0 },
		}

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 1,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				return err != nil
			},
			GetFailoverModel: func(_ context.Context, _ *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				return nil, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			retryConfig:    retryCfg,
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		_, err := wrapped.Stream(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.Error(t, err)
		require.Contains(t, err.Error(), "returned nil model at attempt")
		require.Equal(t, int32(1), atomic.LoadInt32(&m1Calls))
	})

	t.Run("Stream_ContextCanceledDuringFailover", func(t *testing.T) {
		streamErr := errors.New("m1 fails")
		var m1Calls int32
		var failoverModelCalled int32

		m1 := newFakeChatModel(nil, func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
			atomic.AddInt32(&m1Calls, 1)
			return nil, streamErr
		})

		ctx, cancel := context.WithCancel(context.Background())

		retryCfg := &ModelRetryConfig{
			MaxRetries:  0,
			IsRetryAble: func(_ context.Context, err error) bool { return false },
			BackoffFunc: func(_ context.Context, _ int) time.Duration { return 0 },
		}

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 3,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				cancel()
				return err != nil
			},
			GetFailoverModel: func(_ context.Context, _ *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				atomic.AddInt32(&failoverModelCalled, 1)
				return nil, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			retryConfig:    retryCfg,
			failoverConfig: failoverCfg,
		})

		ctx = withTypedChatModelAgentExecCtx(ctx, &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		_, err := wrapped.Stream(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.Error(t, err)
		require.ErrorIs(t, err, context.Canceled)
		require.Equal(t, int32(1), atomic.LoadInt32(&m1Calls))
		require.Equal(t, int32(0), atomic.LoadInt32(&failoverModelCalled))
	})
}

func TestErrStreamCanceled_Failover(t *testing.T) {
	t.Run("Stream_NeverFailedOver", func(t *testing.T) {
		var m1Calls int32
		var failoverCalled int32

		m1 := newFakeChatModel(nil, func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
			atomic.AddInt32(&m1Calls, 1)
			return streamWithMidError([]*schema.Message{
				schema.AssistantMessage("partial", nil),
			}, ErrStreamCanceled), nil
		})

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 2,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				atomic.AddInt32(&failoverCalled, 1)
				return true
			},
			GetFailoverModel: func(_ context.Context, _ *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				t.Fatal("GetFailoverModel should not be called for ErrStreamCanceled")
				return nil, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		_, err := wrapped.Stream(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.Error(t, err)
		require.True(t, errors.Is(err, ErrStreamCanceled))
		require.Equal(t, int32(1), atomic.LoadInt32(&m1Calls))
		require.Equal(t, int32(0), atomic.LoadInt32(&failoverCalled))
	})

	t.Run("Generate_NeverFailedOver", func(t *testing.T) {
		var m1Calls int32
		var failoverCalled int32

		m1 := newFakeChatModel(func(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
			atomic.AddInt32(&m1Calls, 1)
			return nil, ErrStreamCanceled
		}, nil)

		failoverCfg := &ModelFailoverConfig[*schema.Message]{
			MaxRetries: 2,
			ShouldFailover: func(_ context.Context, _ *schema.Message, err error) bool {
				atomic.AddInt32(&failoverCalled, 1)
				return true
			},
			GetFailoverModel: func(_ context.Context, _ *FailoverContext[*schema.Message]) (model.BaseChatModel, []*schema.Message, error) {
				t.Fatal("GetFailoverModel should not be called for ErrStreamCanceled")
				return nil, nil, nil
			},
		}

		wrapped := buildModelWrappers[*schema.Message](m1, &modelWrapperConfig{
			failoverConfig: failoverCfg,
		})

		ctx := withTypedChatModelAgentExecCtx(context.Background(), &chatModelAgentExecCtx{
			failoverLastSuccessModel: m1,
		})
		_, err := wrapped.Generate(ctx, []*schema.Message{schema.UserMessage("hi")})
		require.Error(t, err)
		require.True(t, errors.Is(err, ErrStreamCanceled))
		require.Equal(t, int32(1), atomic.LoadInt32(&m1Calls))
		require.Equal(t, int32(0), atomic.LoadInt32(&failoverCalled))
	})
}
