/*
 * Copyright 2025 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"
	"fmt"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

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

type agenticAgentEvent = TypedAgentEvent[*schema.AgenticMessage]

func agenticToolCallMsg(toolName, callID, args string) *schema.AgenticMessage {
	return &schema.AgenticMessage{
		Role: schema.AgenticRoleTypeAssistant,
		ContentBlocks: []*schema.ContentBlock{
			{
				Type:             schema.ContentBlockTypeFunctionToolCall,
				FunctionToolCall: &schema.FunctionToolCall{Name: toolName, CallID: callID, Arguments: args},
			},
		},
	}
}

type sequentialAgenticModel struct {
	responses []*schema.AgenticMessage
	callCount int32
}

func (m *sequentialAgenticModel) Generate(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
	idx := atomic.AddInt32(&m.callCount, 1) - 1
	if int(idx) >= len(m.responses) {
		return nil, fmt.Errorf("sequentialAgenticModel: no more responses (call #%d)", idx)
	}
	return m.responses[idx], nil
}

func (m *sequentialAgenticModel) Stream(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
	result, err := m.Generate(ctx, input, opts...)
	if err != nil {
		return nil, err
	}
	r, w := schema.Pipe[*schema.AgenticMessage](1)
	go func() { defer w.Close(); w.Send(result, nil) }()
	return r, nil
}

type agenticEchoTool struct {
	name string
}

func (t *agenticEchoTool) Info(_ context.Context) (*schema.ToolInfo, error) {
	return &schema.ToolInfo{Name: t.name, Desc: "echoes input"}, nil
}

func (t *agenticEchoTool) InvokableRun(_ context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) {
	return "echo:" + argumentsInJSON, nil
}

type agenticInterruptTool struct {
	name string
}

func (t *agenticInterruptTool) Info(_ context.Context) (*schema.ToolInfo, error) {
	return &schema.ToolInfo{Name: t.name, Desc: "interrupts on first call, returns on resume"}, nil
}

func (t *agenticInterruptTool) InvokableRun(ctx context.Context, _ string, _ ...tool.Option) (string, error) {
	wasInterrupted, _, _ := tool.GetInterruptState[any](ctx)
	if !wasInterrupted {
		return "", tool.Interrupt(ctx, "need_approval")
	}
	isResume, hasData, data := tool.GetResumeContext[string](ctx)
	if isResume && hasData {
		return "approved:" + data, nil
	}
	return "resumed_no_data", nil
}

type agenticArgCaptureTool struct {
	name     string
	onInvoke func(args string) string
}

func (t *agenticArgCaptureTool) Info(_ context.Context) (*schema.ToolInfo, error) {
	return &schema.ToolInfo{Name: t.name, Desc: "captures args"}, nil
}

func (t *agenticArgCaptureTool) InvokableRun(_ context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) {
	return t.onInvoke(argumentsInJSON), nil
}

type agenticSignalTool struct {
	name    string
	started chan struct{}
	result  string
	done    chan struct{}
	once    sync.Once
}

func (t *agenticSignalTool) Info(_ context.Context) (*schema.ToolInfo, error) {
	return &schema.ToolInfo{Name: t.name, Desc: "blocks until finish() is called"}, nil
}

func (t *agenticSignalTool) InvokableRun(_ context.Context, _ string, _ ...tool.Option) (string, error) {
	t.once.Do(func() { t.done = make(chan struct{}) })
	select {
	case t.started <- struct{}{}:
	default:
	}
	<-t.done
	return t.result, nil
}

func (t *agenticSignalTool) finish() {
	t.once.Do(func() { t.done = make(chan struct{}) })
	close(t.done)
}

type agenticReactTestStore struct {
	m map[string][]byte
}

func (s *agenticReactTestStore) Set(_ context.Context, key string, value []byte) error {
	s.m[key] = value
	return nil
}

func (s *agenticReactTestStore) Get(_ context.Context, key string) ([]byte, bool, error) {
	v, ok := s.m[key]
	return v, ok, nil
}

func newAgenticAgent(t *testing.T, ctx context.Context, mdl model.BaseModel[*schema.AgenticMessage], tools []tool.BaseTool) TypedAgent[*schema.AgenticMessage] {
	t.Helper()
	config := &TypedChatModelAgentConfig[*schema.AgenticMessage]{
		Name:        t.Name(),
		Description: "test agentic agent",
		Model:       mdl,
	}
	if len(tools) > 0 {
		config.ToolsConfig = ToolsConfig{
			ToolsNodeConfig: compose.ToolsNodeConfig{
				Tools: tools,
			},
		}
	}
	agent, err := NewTypedChatModelAgent(ctx, config)
	require.NoError(t, err)
	return agent
}

func newAgenticRunner(t *testing.T, ctx context.Context, mdl model.BaseModel[*schema.AgenticMessage], tools []tool.BaseTool) *TypedRunner[*schema.AgenticMessage] {
	t.Helper()
	agent := newAgenticAgent(t, ctx, mdl, tools)
	return NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{Agent: agent})
}

func newAgenticRunnerWithStore(t *testing.T, ctx context.Context, mdl model.BaseModel[*schema.AgenticMessage], tools []tool.BaseTool, store CheckPointStore) *TypedRunner[*schema.AgenticMessage] {
	t.Helper()
	agent := newAgenticAgent(t, ctx, mdl, tools)
	return NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
		Agent:           agent,
		CheckPointStore: store,
	})
}

func drainAgenticEvents(iter *AsyncIterator[*agenticAgentEvent]) []*agenticAgentEvent {
	var events []*agenticAgentEvent
	for {
		ev, ok := iter.Next()
		if !ok {
			break
		}
		events = append(events, ev)
	}
	return events
}

func lastAgenticEvent(events []*agenticAgentEvent) *agenticAgentEvent {
	if len(events) == 0 {
		return nil
	}
	return events[len(events)-1]
}

func firstAgenticEventError(events []*agenticAgentEvent) error {
	for _, ev := range events {
		if ev.Err != nil {
			return ev.Err
		}
	}
	return nil
}

func findInterruptEvent(events []*agenticAgentEvent) *agenticAgentEvent {
	for _, ev := range events {
		if ev.Action != nil && ev.Action.Interrupted != nil {
			return ev
		}
	}
	return nil
}

func TestAgenticReact_BasicInvoke(t *testing.T) {
	ctx := context.Background()

	mdl := &sequentialAgenticModel{
		responses: []*schema.AgenticMessage{
			agenticToolCallMsg("echo", "call-1", `"hello"`),
			agenticMsg("done: echo result received"),
		},
	}

	runner := newAgenticRunner(t, ctx, mdl, []tool.BaseTool{&agenticEchoTool{name: "echo"}})
	events := drainAgenticEvents(runner.Query(ctx, "test input"))
	last := lastAgenticEvent(events)

	require.NotNil(t, last)
	require.Nil(t, last.Err)
	require.NotNil(t, last.Output)
	require.NotNil(t, last.Output.MessageOutput)
	assert.Equal(t, "done: echo result received", agenticTextContent(last.Output.MessageOutput.Message))
	assert.Equal(t, int32(2), atomic.LoadInt32(&mdl.callCount))
}

func TestAgenticReact_MultiTurnToolCalling(t *testing.T) {
	ctx := context.Background()

	mdl := &sequentialAgenticModel{
		responses: []*schema.AgenticMessage{
			agenticToolCallMsg("echo", "call-1", `"step1"`),
			agenticToolCallMsg("echo", "call-2", `"step2"`),
			agenticToolCallMsg("echo", "call-3", `"step3"`),
			agenticMsg("all done"),
		},
	}

	runner := newAgenticRunner(t, ctx, mdl, []tool.BaseTool{&agenticEchoTool{name: "echo"}})
	events := drainAgenticEvents(runner.Query(ctx, "do three steps"))
	last := lastAgenticEvent(events)

	require.NotNil(t, last)
	require.Nil(t, last.Err)
	require.NotNil(t, last.Output)
	require.NotNil(t, last.Output.MessageOutput)
	assert.Equal(t, "all done", agenticTextContent(last.Output.MessageOutput.Message))
	assert.Equal(t, int32(4), atomic.LoadInt32(&mdl.callCount))
}

func TestAgenticReact_Stream(t *testing.T) {
	ctx := context.Background()

	mdl := &sequentialAgenticModel{
		responses: []*schema.AgenticMessage{
			agenticToolCallMsg("echo", "call-1", `"hello"`),
			agenticMsg("stream done"),
		},
	}

	agent := newAgenticAgent(t, ctx, mdl, []tool.BaseTool{&agenticEchoTool{name: "echo"}})
	runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
		Agent:           agent,
		EnableStreaming: true,
	})

	events := drainAgenticEvents(runner.Query(ctx, "stream test"))

	var finalText string
	for _, ev := range events {
		if ev.Output != nil && ev.Output.MessageOutput != nil {
			msg, err := ev.Output.MessageOutput.GetMessage()
			if err == nil && msg != nil {
				txt := agenticTextContent(msg)
				if txt != "" {
					finalText = txt
				}
			}
		}
	}

	assert.Equal(t, "stream done", finalText)
}

func TestAgenticReact_MaxIterations(t *testing.T) {
	ctx := context.Background()

	t.Run("within_limit", func(t *testing.T) {
		mdl := &sequentialAgenticModel{
			responses: []*schema.AgenticMessage{
				agenticToolCallMsg("echo", "c1", `"1"`),
				agenticToolCallMsg("echo", "c2", `"2"`),
				agenticMsg("done within limit"),
			},
		}

		runner := newAgenticRunner(t, ctx, mdl, []tool.BaseTool{&agenticEchoTool{name: "echo"}})
		events := drainAgenticEvents(runner.Query(ctx, "go"))
		last := lastAgenticEvent(events)

		require.NotNil(t, last)
		require.Nil(t, last.Err)
		require.NotNil(t, last.Output)
		require.NotNil(t, last.Output.MessageOutput)
		assert.Equal(t, "done within limit", agenticTextContent(last.Output.MessageOutput.Message))
	})

	t.Run("exceeded", func(t *testing.T) {
		responses := make([]*schema.AgenticMessage, 25)
		for i := range responses {
			responses[i] = agenticToolCallMsg("echo", fmt.Sprintf("c%d", i), `"x"`)
		}

		mdl := &sequentialAgenticModel{responses: responses}
		config := &TypedChatModelAgentConfig[*schema.AgenticMessage]{
			Name:          "exceed-agent",
			Description:   "test max iterations exceeded",
			Model:         mdl,
			MaxIterations: 3,
			ToolsConfig: ToolsConfig{
				ToolsNodeConfig: compose.ToolsNodeConfig{
					Tools: []tool.BaseTool{&agenticEchoTool{name: "echo"}},
				},
			},
		}
		agent, err := NewTypedChatModelAgent(ctx, config)
		require.NoError(t, err)

		runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{Agent: agent})
		events := drainAgenticEvents(runner.Query(ctx, "go"))
		last := lastAgenticEvent(events)

		require.NotNil(t, last)
		require.NotNil(t, last.Err)
		assert.ErrorIs(t, last.Err, ErrExceedMaxIterations)
	})
}

func TestAgenticReact_ReturnDirectly(t *testing.T) {
	ctx := context.Background()

	mdl := &sequentialAgenticModel{
		responses: []*schema.AgenticMessage{
			// Model calls the return-directly tool.
			agenticToolCallMsg("direct", "call-1", `"final answer"`),
		},
	}

	agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
		Name:        t.Name(),
		Description: "test",
		Model:       mdl,
		ToolsConfig: ToolsConfig{
			ToolsNodeConfig: compose.ToolsNodeConfig{
				Tools: []tool.BaseTool{&agenticEchoTool{name: "direct"}},
			},
			ReturnDirectly: map[string]bool{"direct": true},
		},
	})
	require.NoError(t, err)

	t.Run("Invoke", func(t *testing.T) {
		atomic.StoreInt32(&mdl.callCount, 0)
		runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
			Agent: agent, EnableStreaming: false,
		})
		events := drainAgenticEvents(runner.Query(ctx, "test"))

		// Model should be called only once (for the tool call), not a second
		// time, because the tool is return-directly.
		assert.Equal(t, int32(1), atomic.LoadInt32(&mdl.callCount))

		// Find the final output event — should be the return-directly tool result.
		last := lastAgenticEvent(events)
		require.NotNil(t, last)
		require.Nil(t, last.Err)
		require.NotNil(t, last.Output)
		require.NotNil(t, last.Output.MessageOutput)

		msg := last.Output.MessageOutput.Message
		require.NotNil(t, msg)
		require.GreaterOrEqual(t, len(msg.ContentBlocks), 1)
		ftr := msg.ContentBlocks[0].FunctionToolResult
		require.NotNil(t, ftr, "expected FunctionToolResult in final output, got type=%v", msg.ContentBlocks[0].Type)
		assert.Equal(t, "call-1", ftr.CallID)
	})

	t.Run("Stream", func(t *testing.T) {
		atomic.StoreInt32(&mdl.callCount, 0)
		runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
			Agent: agent, EnableStreaming: true,
		})
		events := drainAgenticEvents(runner.Query(ctx, "test"))

		assert.Equal(t, int32(1), atomic.LoadInt32(&mdl.callCount))

		last := lastAgenticEvent(events)
		require.NotNil(t, last)
		require.Nil(t, last.Err)
		require.NotNil(t, last.Output)
		require.NotNil(t, last.Output.MessageOutput)

		mo := last.Output.MessageOutput
		if mo.IsStreaming {
			var finalMsg *schema.AgenticMessage
			for {
				chunk, recvErr := mo.MessageStream.Recv()
				if recvErr != nil {
					break
				}
				finalMsg = chunk
			}
			require.NotNil(t, finalMsg)
			require.GreaterOrEqual(t, len(finalMsg.ContentBlocks), 1)
			ftr := finalMsg.ContentBlocks[0].FunctionToolResult
			require.NotNil(t, ftr)
			assert.Equal(t, "call-1", ftr.CallID)
		} else {
			msg := mo.Message
			require.NotNil(t, msg)
			require.GreaterOrEqual(t, len(msg.ContentBlocks), 1)
			ftr := msg.ContentBlocks[0].FunctionToolResult
			require.NotNil(t, ftr)
			assert.Equal(t, "call-1", ftr.CallID)
		}
	})
}

func TestAgenticReact_CancelAfterChatModel(t *testing.T) {
	ctx := context.Background()

	toolStarted := make(chan struct{}, 1)
	var modelCallCount int32
	mdl := &mockAgenticModel{
		generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
			count := atomic.AddInt32(&modelCallCount, 1)
			switch count {
			case 1:
				return agenticToolCallMsg("slow", "c1", `"hi"`), nil
			case 2:
				return agenticToolCallMsg("slow", "c2", `"hi2"`), nil
			default:
				return agenticMsg("should not reach"), nil
			}
		},
	}

	slowTool := &agenticSignalTool{
		name:    "slow",
		started: toolStarted,
		result:  "slow result",
	}

	agent := newAgenticAgent(t, ctx, mdl, []tool.BaseTool{slowTool})

	cancelOpt, cancelFn := WithCancel()
	iter := agent.Run(ctx, &TypedAgentInput[*schema.AgenticMessage]{
		Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("trigger cancel")},
	}, cancelOpt)

	<-toolStarted

	go func() {
		handle, _ := cancelFn(WithAgentCancelMode(CancelAfterChatModel))
		_ = handle.Wait()
	}()

	time.Sleep(10 * time.Millisecond)
	slowTool.finish()

	var capturedErr error
	for {
		ev, ok := iter.Next()
		if !ok {
			break
		}
		if ev.Err != nil {
			capturedErr = ev.Err
		}
	}
	require.Error(t, capturedErr, "expected CancelError event")
	var cancelErr *CancelError
	require.ErrorAs(t, capturedErr, &cancelErr)
}

func TestAgenticReact_CancelAfterToolCalls(t *testing.T) {
	ctx := context.Background()

	toolStarted := make(chan struct{}, 1)
	var modelCallCount int32
	mdl := &mockAgenticModel{
		generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
			count := atomic.AddInt32(&modelCallCount, 1)
			if count == 1 {
				return agenticToolCallMsg("slow", "c1", `"hi"`), nil
			}
			return agenticMsg("should not reach on second call"), nil
		},
	}

	slowTool := &agenticSignalTool{
		name:    "slow",
		started: toolStarted,
		result:  "slow result",
	}

	agent := newAgenticAgent(t, ctx, mdl, []tool.BaseTool{slowTool})

	cancelOpt, cancelFn := WithCancel()
	iter := agent.Run(ctx, &TypedAgentInput[*schema.AgenticMessage]{
		Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("trigger cancel")},
	}, cancelOpt)

	<-toolStarted

	go func() {
		handle, _ := cancelFn(WithAgentCancelMode(CancelAfterToolCalls))
		_ = handle.Wait()
	}()

	time.Sleep(10 * time.Millisecond)
	slowTool.finish()

	var capturedErr error
	for {
		ev, ok := iter.Next()
		if !ok {
			break
		}
		if ev.Err != nil {
			capturedErr = ev.Err
		}
	}
	require.Error(t, capturedErr, "expected CancelError event")
	var cancelErr *CancelError
	require.ErrorAs(t, capturedErr, &cancelErr)
	assert.Equal(t, int32(1), atomic.LoadInt32(&modelCallCount))
}

func TestAgenticReact_DoubleInterruptResume(t *testing.T) {
	ctx := context.Background()

	var modelCallCount int32
	mdl := &mockAgenticModel{
		generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
			count := atomic.AddInt32(&modelCallCount, 1)
			switch count {
			case 1:
				return agenticToolCallMsg("approval_tool", "c1", `"first"`), nil
			case 2:
				return agenticToolCallMsg("approval_tool", "c2", `"second"`), nil
			case 3:
				return agenticMsg("all approved"), nil
			default:
				return nil, fmt.Errorf("unexpected call #%d", count)
			}
		},
	}

	store := &agenticReactTestStore{m: map[string][]byte{}}
	runner := newAgenticRunnerWithStore(t, ctx, mdl, []tool.BaseTool{&agenticInterruptTool{name: "approval_tool"}}, store)

	events1 := drainAgenticEvents(runner.Query(ctx, "approve twice", WithCheckPointID("dbl-cp")))
	require.NoError(t, firstAgenticEventError(events1))
	int1Event := findInterruptEvent(events1)
	require.NotNil(t, int1Event, "expected first interrupt")
	int1ID := int1Event.Action.Interrupted.InterruptContexts[0].ID

	iter2, err := runner.ResumeWithParams(ctx, "dbl-cp", &ResumeParams{
		Targets: map[string]any{int1ID: "approved_1"},
	})
	require.NoError(t, err)

	events2 := drainAgenticEvents(iter2)
	require.NoError(t, firstAgenticEventError(events2))
	int2Event := findInterruptEvent(events2)
	require.NotNil(t, int2Event, "expected second interrupt")
	int2ID := int2Event.Action.Interrupted.InterruptContexts[0].ID

	iter3, err := runner.ResumeWithParams(ctx, "dbl-cp", &ResumeParams{
		Targets: map[string]any{int2ID: "approved_2"},
	})
	require.NoError(t, err)

	events3 := drainAgenticEvents(iter3)
	require.NoError(t, firstAgenticEventError(events3))
	last := lastAgenticEvent(events3)

	require.NotNil(t, last)
	require.Nil(t, last.Err)
	require.NotNil(t, last.Output)
	require.NotNil(t, last.Output.MessageOutput)
	assert.Contains(t, agenticTextContent(last.Output.MessageOutput.Message), "all approved")
}

func TestAgenticReact_ResumeThenCancelAfterChatModelCheckpoint(t *testing.T) {
	ctx := context.Background()

	var modelCallCount int32
	mdl := &mockAgenticModel{
		generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
			count := atomic.AddInt32(&modelCallCount, 1)
			switch count {
			case 1:
				return agenticToolCallMsg("approval_tool", "c1", `"first"`), nil
			case 2:
				return agenticToolCallMsg("approval_tool", "c2", `"second"`), nil
			default:
				return nil, fmt.Errorf("unexpected call #%d", count)
			}
		},
	}

	store := &agenticReactTestStore{m: map[string][]byte{}}
	runner := newAgenticRunnerWithStore(t, ctx, mdl, []tool.BaseTool{&agenticInterruptTool{name: "approval_tool"}}, store)

	events1 := drainAgenticEvents(runner.Query(ctx, "approve then cancel", WithCheckPointID("resume-cancel-cp")))
	require.NoError(t, firstAgenticEventError(events1))
	int1Event := findInterruptEvent(events1)
	require.NotNil(t, int1Event, "expected tool interrupt")
	int1ID := int1Event.Action.Interrupted.InterruptContexts[0].ID

	cancelOpt, cancelFn := WithCancel()
	handle, contributed := cancelFn(WithAgentCancelMode(CancelAfterChatModel))
	require.True(t, contributed)

	iter2, err := runner.ResumeWithParams(ctx, "resume-cancel-cp", &ResumeParams{
		Targets: map[string]any{int1ID: "approved"},
	}, cancelOpt)
	require.NoError(t, err)

	events2 := drainAgenticEvents(iter2)
	require.NoError(t, handle.Wait())
	for _, event := range events2 {
		if event.Err == nil {
			continue
		}
		var cancelErr *CancelError
		require.ErrorAs(t, event.Err, &cancelErr)
	}
}

func TestAgenticReact_ChatModelAgent_NoTools(t *testing.T) {
	ctx := context.Background()

	mdl := &mockAgenticModel{
		generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
			return agenticMsg("no tools response"), nil
		},
	}

	runner := newAgenticRunner(t, ctx, mdl, nil)
	events := drainAgenticEvents(runner.Query(ctx, "hello"))
	last := lastAgenticEvent(events)

	require.NotNil(t, last)
	require.Nil(t, last.Err)
	require.NotNil(t, last.Output)
	require.NotNil(t, last.Output.MessageOutput)
	assert.Equal(t, "no tools response", agenticTextContent(last.Output.MessageOutput.Message))
}

func TestAgenticReact_ChatModelAgent_ToolsReceiveArgs(t *testing.T) {
	ctx := context.Background()

	var receivedArgs string
	captureTool := &agenticArgCaptureTool{
		name: "capture",
		onInvoke: func(args string) string {
			receivedArgs = args
			return "captured"
		},
	}

	mdl := &sequentialAgenticModel{
		responses: []*schema.AgenticMessage{
			agenticToolCallMsg("capture", "c1", `{"foo":"bar"}`),
			agenticMsg("done"),
		},
	}

	runner := newAgenticRunner(t, ctx, mdl, []tool.BaseTool{captureTool})
	drainAgenticEvents(runner.Query(ctx, "call capture"))

	assert.Equal(t, `{"foo":"bar"}`, receivedArgs)
}

func TestCoverage_AgenticReact_Streaming(t *testing.T) {
	ctx := context.Background()

	m := &mockAgenticModel{
		streamFn: func(_ context.Context, input []*schema.AgenticMessage, _ ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
			r, w := schema.Pipe[*schema.AgenticMessage](1)
			go func() {
				defer w.Close()
				w.Send(&schema.AgenticMessage{
					Role: schema.AgenticRoleTypeAssistant,
					ContentBlocks: []*schema.ContentBlock{
						schema.NewContentBlock(&schema.AssistantGenText{Text: "streamed response"}),
					},
				}, nil)
			}()
			return r, nil
		},
	}

	echoTool := &agenticEchoTool{name: "echo"}

	agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
		Name:        "stream-react",
		Description: "streaming agentic react",
		Model:       m,
		ToolsConfig: ToolsConfig{
			ToolsNodeConfig: compose.ToolsNodeConfig{
				Tools: []tool.BaseTool{echoTool},
			},
		},
	})
	require.NoError(t, err)

	runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
		Agent:           agent,
		EnableStreaming: true,
	})

	iter := runner.Query(ctx, "stream me")

	var events []*TypedAgentEvent[*schema.AgenticMessage]
	for {
		event, ok := iter.Next()
		if !ok {
			break
		}
		if event.Output != nil && event.Output.MessageOutput != nil && event.Output.MessageOutput.IsStreaming {
			stream := event.Output.MessageOutput.MessageStream
			for {
				_, sErr := stream.Recv()
				if sErr != nil {
					break
				}
			}
		}
		events = append(events, event)
	}

	require.NotEmpty(t, events)
	assertAgenticEventRoleFields(t, events)
}

func TestCoverage_ConcatMessageStream_Agentic(t *testing.T) {
	t.Run("Success", func(t *testing.T) {
		r, w := schema.Pipe[*schema.AgenticMessage](2)
		go func() {
			defer w.Close()
			w.Send(&schema.AgenticMessage{
				Role: schema.AgenticRoleTypeAssistant,
				ContentBlocks: []*schema.ContentBlock{
					schema.NewContentBlock(&schema.AssistantGenText{Text: "Hello "}),
				},
			}, nil)
			w.Send(&schema.AgenticMessage{
				Role: schema.AgenticRoleTypeAssistant,
				ContentBlocks: []*schema.ContentBlock{
					schema.NewContentBlock(&schema.AssistantGenText{Text: "world"}),
				},
			}, nil)
		}()

		result, err := concatMessageStream(r)
		assert.NoError(t, err)
		assert.NotNil(t, result)
	})

	t.Run("ErrorDuringRecv", func(t *testing.T) {
		r, w := schema.Pipe[*schema.AgenticMessage](2)
		go func() {
			w.Send(nil, fmt.Errorf("recv error"))
			w.Close()
		}()

		_, err := concatMessageStream(r)
		assert.Error(t, err)
	})
}

func TestCoverage_AgenticReact_InterruptResume(t *testing.T) {
	ctx := context.Background()

	interruptTool := &agenticInterruptTool{name: "approval"}

	var callIdx int32
	m := &mockAgenticModel{
		generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
			idx := atomic.AddInt32(&callIdx, 1)
			if idx == 1 {
				return agenticToolCallMsg("approval", "call1", `{}`), nil
			}
			return agenticMsg("approved and done"), nil
		},
	}

	agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
		Name:        "interrupt-agent",
		Description: "tests interrupt and resume",
		Model:       m,
		ToolsConfig: ToolsConfig{
			ToolsNodeConfig: compose.ToolsNodeConfig{
				Tools: []tool.BaseTool{interruptTool},
			},
		},
	})
	require.NoError(t, err)

	store := newDTTestStore()
	runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
		Agent:           agent,
		CheckPointStore: store,
	})

	iter := runner.Run(ctx, []*schema.AgenticMessage{
		schema.UserAgenticMessage("need approval"),
	}, WithCheckPointID("cp-int"))

	var interruptEvent *TypedAgentEvent[*schema.AgenticMessage]
	for {
		event, ok := iter.Next()
		if !ok {
			break
		}
		if event.Action != nil && event.Action.Interrupted != nil {
			interruptEvent = event
		}
	}

	require.NotNil(t, interruptEvent, "should have interrupt event")

	var rootCauseID string
	for _, intCtx := range interruptEvent.Action.Interrupted.InterruptContexts {
		if intCtx.IsRootCause {
			rootCauseID = intCtx.ID
			break
		}
	}
	require.NotEmpty(t, rootCauseID)

	resumeIter, err := runner.ResumeWithParams(ctx, "cp-int", &ResumeParams{
		Targets: map[string]any{rootCauseID: "approved"},
	})
	require.NoError(t, err)

	var events []*TypedAgentEvent[*schema.AgenticMessage]
	for {
		event, ok := resumeIter.Next()
		if !ok {
			break
		}
		events = append(events, event)
	}
	require.NotEmpty(t, events)
}

func TestCoverage_AgenticMessageHasToolCalls(t *testing.T) {
	t.Run("NilMessage", func(t *testing.T) {
		assert.False(t, agenticMessageHasToolCalls(nil))
	})

	t.Run("NoToolCalls", func(t *testing.T) {
		msg := agenticMsg("just text")
		assert.False(t, agenticMessageHasToolCalls(msg))
	})

	t.Run("HasToolCalls", func(t *testing.T) {
		msg := agenticToolCallMsg("tool1", "id1", `{}`)
		assert.True(t, agenticMessageHasToolCalls(msg))
	})

	t.Run("NilBlock", func(t *testing.T) {
		msg := &schema.AgenticMessage{
			ContentBlocks: []*schema.ContentBlock{nil},
		}
		assert.False(t, agenticMessageHasToolCalls(msg))
	})

	t.Run("ToolCallBlockNilFunctionToolCall", func(t *testing.T) {
		msg := &schema.AgenticMessage{
			ContentBlocks: []*schema.ContentBlock{
				{Type: schema.ContentBlockTypeFunctionToolCall, FunctionToolCall: nil},
			},
		}
		assert.False(t, agenticMessageHasToolCalls(msg))
	})
}

func TestCoverage_ChatModelAgent_StreamError(t *testing.T) {
	ctx := context.Background()

	testErr := errors.New("stream failed")
	m := &mockAgenticModel{
		streamFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
			return nil, testErr
		},
	}

	agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
		Name:        "stream-error-agent",
		Description: "tests stream error",
		Model:       m,
	})
	require.NoError(t, err)

	runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
		Agent:           agent,
		EnableStreaming: true,
	})

	iter := runner.Query(ctx, "trigger stream error")

	var capturedErr error
	for {
		event, ok := iter.Next()
		if !ok {
			break
		}
		if event.Err != nil {
			capturedErr = event.Err
		}
	}
	require.Error(t, capturedErr, "should propagate stream error")
}

func TestCoverage_AgenticReact_GobStateRoundTrip(t *testing.T) {
	ctx := context.Background()

	var callIdx int32
	m := &mockAgenticModel{
		generateFn: func(_ context.Context, _ []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
			idx := atomic.AddInt32(&callIdx, 1)
			if idx == 1 {
				return agenticToolCallMsg("interrupt_tool", "call1", `{}`), nil
			}
			return agenticMsg("completed"), nil
		},
	}

	interruptTool := &agenticInterruptTool{name: "interrupt_tool"}

	agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
		Name:        "gob-test",
		Description: "tests gob state round trip",
		Model:       m,
		ToolsConfig: ToolsConfig{
			ToolsNodeConfig: compose.ToolsNodeConfig{
				Tools: []tool.BaseTool{interruptTool},
			},
		},
	})
	require.NoError(t, err)

	store := newDTTestStore()
	runner := NewTypedRunner(TypedRunnerConfig[*schema.AgenticMessage]{
		Agent:           agent,
		CheckPointStore: store,
	})

	iter := runner.Run(ctx, []*schema.AgenticMessage{
		schema.UserAgenticMessage("test gob"),
	}, WithCheckPointID("gob-cp"))

	var interrupted bool
	var interruptEvent *TypedAgentEvent[*schema.AgenticMessage]
	for {
		event, ok := iter.Next()
		if !ok {
			break
		}
		if event.Action != nil && event.Action.Interrupted != nil {
			interrupted = true
			interruptEvent = event
		}
	}

	if !interrupted || interruptEvent == nil {
		t.Skip("no interrupt occurred, skipping gob round-trip test")
	}

	_, exists, err := store.Get(ctx, "gob-cp")
	assert.NoError(t, err)
	assert.True(t, exists, "checkpoint should be saved")

	var rootCauseID string
	for _, intCtx := range interruptEvent.Action.Interrupted.InterruptContexts {
		if intCtx.IsRootCause {
			rootCauseID = intCtx.ID
			break
		}
	}
	require.NotEmpty(t, rootCauseID)

	resumeIter, err := runner.ResumeWithParams(ctx, "gob-cp", &ResumeParams{
		Targets: map[string]any{rootCauseID: "approved"},
	})
	require.NoError(t, err)

	var resumed bool
	for {
		event, ok := resumeIter.Next()
		if !ok {
			break
		}
		if event.Output != nil && event.Output.MessageOutput != nil {
			resumed = true
		}
	}
	assert.True(t, resumed, "should successfully resume from gob checkpoint")
}

func TestCoverage_GetMessageFromTypedWrappedEvent_Agentic(t *testing.T) {
	t.Run("NilOutput", func(t *testing.T) {
		wrapper := &typedAgentEventWrapper[*schema.AgenticMessage]{
			event: &TypedAgentEvent[*schema.AgenticMessage]{},
		}
		msg, err := getMessageFromTypedWrappedEvent(wrapper)
		assert.NoError(t, err)
		assert.Nil(t, msg)
	})

	t.Run("NonStreaming", func(t *testing.T) {
		expected := agenticMsg("hello")
		wrapper := &typedAgentEventWrapper[*schema.AgenticMessage]{
			event: &TypedAgentEvent[*schema.AgenticMessage]{
				Output: &TypedAgentOutput[*schema.AgenticMessage]{
					MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
						Message: expected,
					},
				},
			},
		}
		msg, err := getMessageFromTypedWrappedEvent(wrapper)
		assert.NoError(t, err)
		assert.Equal(t, expected, msg)
	})

	t.Run("StreamingAlreadyConcatenated", func(t *testing.T) {
		expected := agenticMsg("already concatenated")
		wrapper := &typedAgentEventWrapper[*schema.AgenticMessage]{
			concatenatedMessage: expected,
			event: &TypedAgentEvent[*schema.AgenticMessage]{
				Output: &TypedAgentOutput[*schema.AgenticMessage]{
					MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
						IsStreaming: true,
					},
				},
			},
		}
		msg, err := getMessageFromTypedWrappedEvent(wrapper)
		assert.NoError(t, err)
		assert.Equal(t, expected, msg)
	})

	t.Run("StreamingWithPriorError", func(t *testing.T) {
		testErr := errors.New("prior stream error")
		wrapper := &typedAgentEventWrapper[*schema.AgenticMessage]{
			event: &TypedAgentEvent[*schema.AgenticMessage]{
				Output: &TypedAgentOutput[*schema.AgenticMessage]{
					MessageOutput: &TypedMessageVariant[*schema.AgenticMessage]{
						IsStreaming: true,
					},
				},
			},
		}
		wrapper.StreamErr = testErr
		msg, err := getMessageFromTypedWrappedEvent(wrapper)
		assert.Equal(t, testErr, err)
		assert.Nil(t, msg)
	})
}

func TestCoverage_GetMessageFromWrappedEvent_ErrorPaths(t *testing.T) {
	t.Run("NilOutput", func(t *testing.T) {
		wrapper := &agentEventWrapper{
			AgentEvent: &AgentEvent{},
		}
		msg, err := getMessageFromWrappedEvent(wrapper)
		assert.NoError(t, err)
		assert.Nil(t, msg)
	})

	t.Run("NonStreaming", func(t *testing.T) {
		expected := schema.AssistantMessage("hello", nil)
		wrapper := &agentEventWrapper{
			AgentEvent: &AgentEvent{
				Output: &AgentOutput{
					MessageOutput: &MessageVariant{
						Message: expected,
					},
				},
			},
		}
		msg, err := getMessageFromWrappedEvent(wrapper)
		assert.NoError(t, err)
		assert.Equal(t, expected, msg)
	})

	t.Run("AlreadyConcatenated", func(t *testing.T) {
		expected := schema.AssistantMessage("concatenated", nil)
		wrapper := &agentEventWrapper{
			concatenatedMessage: expected,
			AgentEvent: &AgentEvent{
				Output: &AgentOutput{
					MessageOutput: &MessageVariant{
						IsStreaming: true,
					},
				},
			},
		}
		msg, err := getMessageFromWrappedEvent(wrapper)
		assert.NoError(t, err)
		assert.Equal(t, expected, msg)
	})

	t.Run("PriorStreamError", func(t *testing.T) {
		testErr := errors.New("prior error")
		wrapper := &agentEventWrapper{
			AgentEvent: &AgentEvent{
				Output: &AgentOutput{
					MessageOutput: &MessageVariant{
						IsStreaming: true,
					},
				},
			},
		}
		wrapper.StreamErr = testErr
		msg, err := getMessageFromWrappedEvent(wrapper)
		assert.Equal(t, testErr, err)
		assert.Nil(t, msg)
	})
}

func TestCoverage_ConsumeStream_ErrorDuringRecv(t *testing.T) {
	testErr := errors.New("stream recv error")
	r, w := schema.Pipe[*schema.Message](2)
	go func() {
		w.Send(schema.AssistantMessage("partial", nil), nil)
		w.Send(nil, testErr)
		w.Close()
	}()

	wrapper := &agentEventWrapper{
		AgentEvent: &AgentEvent{
			Output: &AgentOutput{
				MessageOutput: &MessageVariant{
					IsStreaming:   true,
					MessageStream: r,
				},
			},
		},
	}

	wrapper.consumeStream()

	assert.NotNil(t, wrapper.StreamErr)
	assert.Nil(t, wrapper.concatenatedMessage)
}

func TestCoverage_ConsumeStream_EmptyStream(t *testing.T) {
	r, w := schema.Pipe[*schema.Message](1)
	go func() { w.Close() }()

	wrapper := &agentEventWrapper{
		AgentEvent: &AgentEvent{
			Output: &AgentOutput{
				MessageOutput: &MessageVariant{
					IsStreaming:   true,
					MessageStream: r,
				},
			},
		},
	}

	wrapper.consumeStream()

	require.NotNil(t, wrapper.StreamErr)
	assert.Contains(t, wrapper.StreamErr.Error(), "no messages")
}

func TestCoverage_ConsumeStream_MultipleMessages(t *testing.T) {
	r, w := schema.Pipe[*schema.Message](3)
	go func() {
		defer w.Close()
		w.Send(schema.AssistantMessage("chunk1", nil), nil)
		w.Send(schema.AssistantMessage("chunk2", nil), nil)
		w.Send(schema.AssistantMessage("chunk3", nil), nil)
	}()

	wrapper := &agentEventWrapper{
		AgentEvent: &AgentEvent{
			Output: &AgentOutput{
				MessageOutput: &MessageVariant{
					IsStreaming:   true,
					MessageStream: r,
				},
			},
		},
	}

	wrapper.consumeStream()

	assert.Nil(t, wrapper.StreamErr)
	assert.NotNil(t, wrapper.concatenatedMessage)
}

func TestCoverage_ConsumeStream_SingleMessage(t *testing.T) {
	r, w := schema.Pipe[*schema.Message](1)
	go func() {
		defer w.Close()
		w.Send(schema.AssistantMessage("single", nil), nil)
	}()

	wrapper := &agentEventWrapper{
		AgentEvent: &AgentEvent{
			Output: &AgentOutput{
				MessageOutput: &MessageVariant{
					IsStreaming:   true,
					MessageStream: r,
				},
			},
		},
	}

	wrapper.consumeStream()

	assert.Nil(t, wrapper.StreamErr)
	require.NotNil(t, wrapper.concatenatedMessage)
	assert.Equal(t, "single", wrapper.concatenatedMessage.Content)
}

func TestCoverage_ConsumeStream_Idempotent(t *testing.T) {
	r, w := schema.Pipe[*schema.Message](1)
	go func() {
		defer w.Close()
		w.Send(schema.AssistantMessage("once", nil), nil)
	}()

	wrapper := &agentEventWrapper{
		AgentEvent: &AgentEvent{
			Output: &AgentOutput{
				MessageOutput: &MessageVariant{
					IsStreaming:   true,
					MessageStream: r,
				},
			},
		},
	}

	wrapper.consumeStream()
	msg1 := wrapper.concatenatedMessage

	wrapper.consumeStream()
	msg2 := wrapper.concatenatedMessage

	assert.Equal(t, msg1, msg2, "second call should be no-op")
}
