1289 lines
35 KiB
Go
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")
|
|
}
|