Files
cloudwego--eino/adk/agentic_react_test.go
2026-07-13 12:49:10 +08:00

1289 lines
35 KiB
Go

/*
* 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")
}