Files
2026-07-13 12:49:10 +08:00

1095 lines
34 KiB
Go

/*
* Copyright 2026 CloudWeGo Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package adk
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/cloudwego/eino/adk/internal"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
mockModel "github.com/cloudwego/eino/internal/mock/components/model"
"github.com/cloudwego/eino/schema"
)
func isValidUUID(s string) bool {
// UUID v4 format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12 = 36 chars)
if len(s) != 36 {
return false
}
for i, c := range s {
if i == 8 || i == 13 || i == 18 || i == 23 {
if c != '-' {
return false
}
} else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
// collectEvents drains all events from the iterator (non-streaming).
func collectEvents(t *testing.T, iter *AsyncIterator[*AgentEvent]) []*AgentEvent {
t.Helper()
var events []*AgentEvent
for {
event, ok := iter.Next()
if !ok {
break
}
events = append(events, event)
}
return events
}
// Scenario 1: AgentEvent messages have IDs (Generate mode)
func TestMessageID_EventHasID_Generate(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("hello world", nil), nil).
Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestMsgID",
Instruction: "test",
Model: cm,
})
require.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("hi")},
})
events := collectEvents(t, iter)
require.Len(t, events, 1)
require.Nil(t, events[0].Err)
require.NotNil(t, events[0].Output.MessageOutput)
msg := events[0].Output.MessageOutput.Message
require.NotNil(t, msg)
msgID := GetMessageID(msg)
assert.NotEmpty(t, msgID, "event message should have an ID")
assert.True(t, isValidUUID(msgID), "message ID should be a valid UUID, got: %s", msgID)
}
// Scenario 2: Event and state messages share the same ID
func TestMessageID_EventAndStateShareSameID(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("response", nil), nil).
Times(1)
var stateMessagesAfterModel []*schema.Message
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestMsgID",
Instruction: "test",
Model: cm,
Middlewares: []AgentMiddleware{
{
AfterChatModel: func(ctx context.Context, state *ChatModelAgentState) error {
// Capture state messages after model call (including the model output)
stateMessagesAfterModel = make([]*schema.Message, len(state.Messages))
copy(stateMessagesAfterModel, state.Messages)
return nil
},
},
},
})
require.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("hi")},
})
events := collectEvents(t, iter)
require.Len(t, events, 1)
require.Nil(t, events[0].Err)
eventMsg := events[0].Output.MessageOutput.Message
eventMsgID := GetMessageID(eventMsg)
assert.NotEmpty(t, eventMsgID)
// The last message in state should be the model output with the same ID
require.NotEmpty(t, stateMessagesAfterModel)
lastStateMsg := stateMessagesAfterModel[len(stateMessagesAfterModel)-1]
stateMsgID := GetMessageID(lastStateMsg)
assert.Equal(t, eventMsgID, stateMsgID,
"event msg ID (%s) and state msg ID (%s) must match", eventMsgID, stateMsgID)
}
// Scenario 3: Stream — first chunk carries ID, concatenated message has correct ID
func TestMessageID_Stream_FirstChunkOnly(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Stream(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.StreamReaderFromArray([]*schema.Message{
schema.AssistantMessage("chunk1", nil),
schema.AssistantMessage("chunk2", nil),
schema.AssistantMessage("chunk3", nil),
}), nil).
Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestMsgID",
Instruction: "test",
Model: cm,
})
require.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("hi")},
EnableStreaming: true,
})
event, ok := iter.Next()
require.True(t, ok)
require.Nil(t, event.Err)
require.NotNil(t, event.Output.MessageOutput)
require.True(t, event.Output.MessageOutput.IsStreaming)
stream := event.Output.MessageOutput.MessageStream
require.NotNil(t, stream)
var chunks []*schema.Message
for {
msg, err := stream.Recv()
if err != nil {
break
}
chunks = append(chunks, msg)
}
require.GreaterOrEqual(t, len(chunks), 1)
// First chunk should have the ID
firstChunkID := GetMessageID(chunks[0])
assert.NotEmpty(t, firstChunkID, "first chunk should carry the message ID")
assert.True(t, isValidUUID(firstChunkID))
// Subsequent chunks should NOT have the ID in Extra (first-chunk-only injection)
for i := 1; i < len(chunks); i++ {
chunkID := GetMessageID(chunks[i])
assert.Empty(t, chunkID, "chunk %d should not have message ID (first-chunk-only)", i)
}
// No more events
_, ok = iter.Next()
assert.False(t, ok)
}
// Scenario 4: Tool messages have IDs
func TestMessageID_ToolMessagesHaveID(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
fakeTool := &fakeToolForTest{tarCount: 1}
info, err := fakeTool.Info(ctx)
require.NoError(t, err)
generateCount := 0
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.Message, error) {
generateCount++
if generateCount == 1 {
return schema.AssistantMessage("calling tool",
[]schema.ToolCall{{
ID: "tc-1",
Function: schema.FunctionCall{
Name: info.Name,
Arguments: `{"name": "tester"}`,
},
}}), nil
}
return schema.AssistantMessage("done", nil), nil
}).AnyTimes()
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
// Capture tool result messages from state via BeforeChatModel on the 2nd model call.
var toolMsgIDInState string
beforeModelCount := 0
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestMsgID",
Instruction: "test",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{fakeTool},
},
},
Middlewares: []AgentMiddleware{
{
BeforeChatModel: func(ctx context.Context, state *ChatModelAgentState) error {
beforeModelCount++
if beforeModelCount == 2 {
// 2nd model call: state.Messages contains tool result messages
for _, m := range state.Messages {
if m.Role == schema.Tool && m.ToolCallID == "tc-1" {
toolMsgIDInState = GetMessageID(m)
}
}
}
return nil
},
},
},
})
require.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("use tool")},
})
events := collectEvents(t, iter)
// Expect 3 events: model(tool_call) + tool(result) + model(final)
require.Len(t, events, 3)
// Tool event (index 1)
toolEvent := events[1]
require.Nil(t, toolEvent.Err)
require.NotNil(t, toolEvent.Output.MessageOutput)
assert.Equal(t, schema.Tool, toolEvent.Output.MessageOutput.Role)
toolMsg := toolEvent.Output.MessageOutput.Message
require.NotNil(t, toolMsg)
toolMsgID := GetMessageID(toolMsg)
assert.NotEmpty(t, toolMsgID, "tool message should have an ID")
assert.True(t, isValidUUID(toolMsgID))
// All events should have IDs
for i, ev := range events {
require.Nil(t, ev.Err)
require.NotNil(t, ev.Output.MessageOutput)
msg := ev.Output.MessageOutput.Message
require.NotNil(t, msg)
assert.NotEmpty(t, GetMessageID(msg), "event[%d] should have a message ID", i)
}
// The tool message in state should share the same ID as the event tool message.
assert.NotEmpty(t, toolMsgIDInState, "tool message in state should have an ID")
assert.Equal(t, toolMsgID, toolMsgIDInState,
"tool event msg ID (%s) and state msg ID (%s) must match", toolMsgID, toolMsgIDInState)
}
// Scenario 5: Retry — the final accepted result carries a message ID
func TestMessageID_Retry_FinalResultHasID(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
retryErr := errors.New("retryable error")
var callCount int32
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
count := atomic.AddInt32(&callCount, 1)
if count < 3 {
return nil, retryErr
}
return schema.AssistantMessage("Success after retry", nil), nil
}).Times(3)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestMsgID",
Instruction: "test",
Model: cm,
ModelRetryConfig: &ModelRetryConfig{
MaxRetries: 3,
ShouldRetry: func(ctx context.Context, retryCtx *RetryContext) *RetryDecision {
return &RetryDecision{Retry: errors.Is(retryCtx.Err, retryErr)}
},
},
})
require.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("hello")},
})
events := collectEvents(t, iter)
require.Len(t, events, 1)
require.Nil(t, events[0].Err)
msg := events[0].Output.MessageOutput.Message
msgID := GetMessageID(msg)
assert.NotEmpty(t, msgID, "surviving message should have an ID")
assert.True(t, isValidUUID(msgID))
assert.Equal(t, int32(3), atomic.LoadInt32(&callCount))
}
// Scenario 6: WrapModel handler sees model output with ID
func TestMessageID_WrapModelSeesID(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("model output", nil), nil).
Times(1)
var capturedMsgID string
handler := &wrapModelIDCheckHandler{
BaseChatModelAgentMiddleware: &BaseChatModelAgentMiddleware{},
onGenerate: func(result *schema.Message) {
capturedMsgID = GetMessageID(result)
},
}
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestMsgID",
Instruction: "test",
Model: cm,
Handlers: []ChatModelAgentMiddleware{handler},
})
require.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("hi")},
})
events := collectEvents(t, iter)
require.Len(t, events, 1)
require.Nil(t, events[0].Err)
assert.NotEmpty(t, capturedMsgID, "WrapModel handler should see message ID on model output")
assert.True(t, isValidUUID(capturedMsgID))
// The event should carry the same ID
eventMsgID := GetMessageID(events[0].Output.MessageOutput.Message)
assert.Equal(t, capturedMsgID, eventMsgID,
"WrapModel-captured ID (%s) should match event ID (%s)", capturedMsgID, eventMsgID)
}
// wrapModelIDCheckHandler wraps the model to inspect the output for message ID.
type wrapModelIDCheckHandler struct {
*BaseChatModelAgentMiddleware
onGenerate func(result *schema.Message)
}
func (h *wrapModelIDCheckHandler) WrapModel(_ context.Context, m model.BaseChatModel, _ *ModelContext) (model.BaseChatModel, error) {
return &idCheckModelWrapper{inner: m, onGenerate: h.onGenerate}, nil
}
type idCheckModelWrapper struct {
inner model.BaseChatModel
onGenerate func(result *schema.Message)
}
func (w *idCheckModelWrapper) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
result, err := w.inner.Generate(ctx, input, opts...)
if err == nil && w.onGenerate != nil {
w.onGenerate(result)
}
return result, err
}
func (w *idCheckModelWrapper) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
return w.inner.Stream(ctx, input, opts...)
}
// Scenario 7: User input messages do NOT get automatic IDs (they are external, not framework-created).
// Only framework-created messages (model output, tool results, TypedSendEvent) get auto-assigned IDs.
func TestMessageID_UserInputNoAutoID(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
var stateMessagesBeforeModel []*schema.Message
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
// Capture input messages
stateMessagesBeforeModel = make([]*schema.Message, len(input))
copy(stateMessagesBeforeModel, input)
return schema.AssistantMessage("response", nil), nil
}).Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestMsgID",
Instruction: "test",
Model: cm,
})
require.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("hello")},
})
events := collectEvents(t, iter)
require.Len(t, events, 1)
require.Nil(t, events[0].Err)
// User input messages should NOT have auto-assigned IDs.
// Framework only assigns IDs to messages it creates (model output, tool results, SendEvent).
require.NotEmpty(t, stateMessagesBeforeModel)
for i, msg := range stateMessagesBeforeModel {
msgID := GetMessageID(msg)
assert.Empty(t, msgID, "input message[%d] (role=%s) should NOT have auto-assigned ID", i, msg.Role)
}
}
// Scenario 8: Middleware must call EnsureMessageID before SendEvent; pointer identity ensures state consistency
// TestMessageID_SendEvent_MiddlewareMustEnsureID verifies that TypedSendEvent is a pure
// transport and does NOT auto-assign message IDs. Middleware authors must call
// EnsureMessageID themselves before sending.
func TestMessageID_SendEvent_MiddlewareMustEnsureID(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(schema.AssistantMessage("model response", nil), nil).
Times(1)
// Track the message pointer that the middleware creates and writes to both state and event
var middlewareMsg *schema.Message
var stateMsgIDAfterSendEvent string
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestMsgID",
Instruction: "test",
Model: cm,
Middlewares: []AgentMiddleware{
{
AfterChatModel: func(ctx context.Context, state *ChatModelAgentState) error {
// Middleware creates a new message and writes the SAME pointer to both state and event
middlewareMsg = schema.AssistantMessage("middleware injected", nil)
// Middleware is responsible for assigning the ID before sending
EnsureMessageID(middlewareMsg)
// Write to state
state.Messages = append(state.Messages, middlewareMsg)
// Send as event — TypedSendEvent does NOT auto-assign ID
event := EventFromMessage(middlewareMsg, nil, schema.Assistant, "")
err := SendEvent(ctx, event)
if err != nil {
return err
}
// Because we called EnsureMessageID on the shared pointer,
// the state copy also has the ID (pointer identity)
stateMsgIDAfterSendEvent = internal.GetMessageID(middlewareMsg.Extra)
return nil
},
},
},
})
require.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("hi")},
})
var allEvents []*AgentEvent
for {
event, ok := iter.Next()
if !ok {
break
}
allEvents = append(allEvents, event)
}
// We expect at least 2 events: model response + middleware injected message
require.GreaterOrEqual(t, len(allEvents), 2)
// The middleware message pointer should have an ID (assigned by middleware via EnsureMessageID)
require.NotNil(t, middlewareMsg)
middlewareMsgID := GetMessageID(middlewareMsg)
assert.NotEmpty(t, middlewareMsgID, "middleware should have assigned an ID via EnsureMessageID")
assert.True(t, isValidUUID(middlewareMsgID))
// The ID captured right after SendEvent (via pointer identity) should be the same
assert.Equal(t, middlewareMsgID, stateMsgIDAfterSendEvent,
"pointer identity: ID read from state pointer (%s) should match message ID (%s)",
stateMsgIDAfterSendEvent, middlewareMsgID)
// Find the middleware event in the collected events
var middlewareEventMsgID string
for _, ev := range allEvents {
if ev.Err != nil || ev.Output == nil || ev.Output.MessageOutput == nil {
continue
}
msg := ev.Output.MessageOutput.Message
if msg != nil && msg.Content == "middleware injected" {
middlewareEventMsgID = GetMessageID(msg)
break
}
}
assert.Equal(t, middlewareMsgID, middlewareEventMsgID,
"event message ID (%s) should match the middleware message ID (%s)",
middlewareEventMsgID, middlewareMsgID)
}
func TestAttack_ConcatCorruptsIDIfMultipleChunksCarryIt(t *testing.T) {
id := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
msgs := []*schema.Message{
{Role: schema.Assistant, Content: "chunk1", Extra: map[string]any{internal.EinoMsgIDKey: id}},
{Role: schema.Assistant, Content: "chunk2", Extra: map[string]any{internal.EinoMsgIDKey: id}},
{Role: schema.Assistant, Content: "chunk3", Extra: map[string]any{internal.EinoMsgIDKey: id}},
}
concatenated, err := schema.ConcatMessages(msgs)
require.NoError(t, err)
resultID := internal.GetMessageID(concatenated.Extra)
// ConcatMessages string-concatenates duplicate Extra keys, corrupting the ID
assert.NotEqual(t, id, resultID, "ConcatMessages should corrupt the ID when multiple chunks carry it")
assert.NotEqual(t, 36, len(resultID), "corrupted ID should not be 36 chars")
assert.Equal(t, "chunk1chunk2chunk3", concatenated.Content)
}
func TestAttack_ConcatPreservesIDIfOnlyFirstChunkHasIt(t *testing.T) {
id := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
msgs := []*schema.Message{
{Role: schema.Assistant, Content: "chunk1", Extra: map[string]any{internal.EinoMsgIDKey: id}},
{Role: schema.Assistant, Content: "chunk2"},
{Role: schema.Assistant, Content: "chunk3"},
}
concatenated, err := schema.ConcatMessages(msgs)
require.NoError(t, err)
resultID := internal.GetMessageID(concatenated.Extra)
assert.Equal(t, id, resultID, "ID should be preserved when only first chunk carries it")
assert.Equal(t, "chunk1chunk2chunk3", concatenated.Content)
}
func TestAttack_ConcurrentGenerate_NoSharedExtraMutation(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Shared singleton message - same pointer returned every time
sharedMsg := schema.AssistantMessage("shared response", nil)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(sharedMsg, nil).
AnyTimes()
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAttack",
Instruction: "test",
Model: cm,
})
require.NoError(t, err)
const N = 10
ids := make([]string, N)
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func(idx int) {
defer wg.Done()
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("hi")},
})
events := collectEvents(t, iter)
require.Len(t, events, 1)
require.Nil(t, events[0].Err)
msg := events[0].Output.MessageOutput.Message
require.NotNil(t, msg)
ids[idx] = GetMessageID(msg)
}(i)
}
wg.Wait()
// All IDs should be unique and valid
seen := make(map[string]bool)
for i, id := range ids {
assert.NotEmpty(t, id, "goroutine %d should have an ID", i)
assert.True(t, isValidUUID(id), "goroutine %d ID should be valid UUID: %s", i, id)
assert.False(t, seen[id], "goroutine %d has duplicate ID: %s", i, id)
seen[id] = true
}
// The original shared message should NOT have been mutated (or if it was, it should still be valid)
// The important thing is no panic and unique IDs
}
func TestAttack_GenerateCopyDoesNotAffectOriginal(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
originalMsg := schema.AssistantMessage("original", nil)
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
Return(originalMsg, nil).
Times(1)
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestAttack",
Instruction: "test",
Model: cm,
})
require.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("hi")},
})
events := collectEvents(t, iter)
require.Len(t, events, 1)
require.Nil(t, events[0].Err)
eventMsg := events[0].Output.MessageOutput.Message
eventMsgID := GetMessageID(eventMsg)
assert.NotEmpty(t, eventMsgID)
// The ORIGINAL message returned by the model should NOT have an ID
// because wrapGenerateEndpoint copies before mutating
originalID := GetMessageID(originalMsg)
assert.Empty(t, originalID, "original model output should NOT be mutated by ID assignment")
}
// ============================================================
// AgenticMessage Integration Tests
// ============================================================
// TestMessageID_AgenticGenerate verifies that AgenticMessage-typed agents
// get message IDs assigned on Generate output, covering the *schema.AgenticMessage
// branches in EnsureMessageID, GetMessageID, and copyMessage.
func TestMessageID_AgenticGenerate(t *testing.T) {
ctx := context.Background()
agenticResponse := &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: "agentic response"}),
},
}
m := &mockAgenticModel{
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
return agenticResponse, nil
},
}
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
Name: "AgenticMsgID",
Instruction: "test",
Model: m,
})
require.NoError(t, err)
iter := agent.Run(ctx, &TypedAgentInput[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("hi")},
})
event, ok := iter.Next()
require.True(t, ok)
require.Nil(t, event.Err)
require.NotNil(t, event.Output)
require.NotNil(t, event.Output.MessageOutput)
msg := event.Output.MessageOutput.Message
require.NotNil(t, msg)
// Verify via the AgenticMessage-specific public API
msgID := GetMessageID(msg)
assert.NotEmpty(t, msgID, "agentic model output should have message ID")
assert.True(t, isValidUUID(msgID), "agentic message ID should be valid UUID: %s", msgID)
// Original message should NOT be mutated (copyMessage for AgenticMessage branch)
originalID := GetMessageID(agenticResponse)
assert.Empty(t, originalID, "original agentic model output should NOT be mutated")
// Drain iterator
for {
_, ok := iter.Next()
if !ok {
break
}
}
}
// TestMessageID_AgenticStream verifies first-chunk-only ID injection for AgenticMessage streams.
func TestMessageID_AgenticStream(t *testing.T) {
ctx := context.Background()
m := &mockAgenticModel{
generateFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
return nil, errors.New("should not be called")
},
streamFn: func(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
r, w := schema.Pipe[*schema.AgenticMessage](3)
go func() {
defer w.Close()
for i := 0; i < 3; i++ {
w.Send(&schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: "chunk"}),
},
}, nil)
}
}()
return r, nil
},
}
agent, err := NewTypedChatModelAgent(ctx, &TypedChatModelAgentConfig[*schema.AgenticMessage]{
Name: "AgenticStreamMsgID",
Instruction: "test",
Model: m,
})
require.NoError(t, err)
iter := agent.Run(ctx, &TypedAgentInput[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{schema.UserAgenticMessage("hi")},
EnableStreaming: true,
})
event, ok := iter.Next()
require.True(t, ok)
require.Nil(t, event.Err)
require.NotNil(t, event.Output)
require.NotNil(t, event.Output.MessageOutput)
require.True(t, event.Output.MessageOutput.IsStreaming)
stream := event.Output.MessageOutput.MessageStream
require.NotNil(t, stream)
var streamMsgID string
for {
chunk, err := stream.Recv()
if err != nil {
break
}
chunkID := GetMessageID(chunk)
if streamMsgID == "" && chunkID != "" {
streamMsgID = chunkID
} else if chunkID != "" {
// Subsequent chunks should not have ID (first-chunk-only)
t.Errorf("expected only first chunk to have ID, got ID on later chunk: %s", chunkID)
}
}
// Drain remaining events
for {
_, ok := iter.Next()
if !ok {
break
}
}
assert.NotEmpty(t, streamMsgID, "first stream chunk should have message ID")
assert.True(t, isValidUUID(streamMsgID), "stream message ID should be valid UUID: %s", streamMsgID)
}
// TestMessageID_AgenticPublicAPIHelpers tests the batch helpers and ensures
// the AgenticMessage public API variants work correctly.
func TestMessageID_AgenticPublicAPIHelpers(t *testing.T) {
t.Run("EnsureMessageID_idempotent", func(t *testing.T) {
msg := &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: "test"}),
},
}
assert.Empty(t, GetMessageID(msg))
EnsureMessageID(msg)
id1 := GetMessageID(msg)
assert.NotEmpty(t, id1)
assert.True(t, isValidUUID(id1))
// Idempotent: second call should not change the ID
EnsureMessageID(msg)
id2 := GetMessageID(msg)
assert.Equal(t, id1, id2)
})
t.Run("EnsureMessageIDs_batch", func(t *testing.T) {
msgs := []*schema.AgenticMessage{
{Role: schema.AgenticRoleTypeAssistant},
{Role: schema.AgenticRoleTypeUser},
{Role: schema.AgenticRoleTypeAssistant},
}
for _, msg := range msgs {
EnsureMessageID(msg)
}
seen := make(map[string]bool)
for i, msg := range msgs {
id := GetMessageID(msg)
assert.NotEmpty(t, id, "msg[%d] should have ID", i)
assert.True(t, isValidUUID(id), "msg[%d] ID should be valid UUID: %s", i, id)
assert.False(t, seen[id], "msg[%d] has duplicate ID: %s", i, id)
seen[id] = true
}
})
}
// --- Adversarial attack tests for message ID system ---
// TestAttack_PopToolMsgID_DoublePop tests that calling popToolMsgID twice for the
// same key returns "" on second call.
func TestAttack_PopToolMsgID_DoublePop(t *testing.T) {
st := &typedState[*schema.Message]{}
st.setToolMsgID("myTool", "call-1", "uuid-abc")
// First pop returns the ID
id1 := st.popToolMsgID("myTool", "call-1")
assert.Equal(t, "uuid-abc", id1)
// Second pop returns empty
id2 := st.popToolMsgID("myTool", "call-1")
assert.Empty(t, id2, "double-pop should return empty")
// Inner map should be cleaned up
assert.Nil(t, st.ToolMsgIDs["myTool"], "inner map should be removed when empty")
}
// namedFakeToolForTest is a variant of fakeToolForTest with a configurable name.
type namedFakeToolForTest struct {
name string
}
func (t *namedFakeToolForTest) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: t.name,
Desc: t.name + " tool for testing",
ParamsOneOf: schema.NewParamsOneOfByParams(
map[string]*schema.ParameterInfo{
"name": {
Desc: "user name for testing",
Required: true,
Type: schema.String,
},
}),
}, nil
}
func (t *namedFakeToolForTest) InvokableRun(_ context.Context, _ string, _ ...tool.Option) (string, error) {
return `{"say": "ok"}`, nil
}
// TestAttack_ToolMsgIDConsistency_MultipleTools is an integration test: when an agent
// has multiple tools called in one turn, verify that EACH tool's event message ID
// matches its corresponding state message ID.
func TestAttack_ToolMsgIDConsistency_MultipleTools(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
tool1 := &namedFakeToolForTest{name: "greet"}
tool2 := &namedFakeToolForTest{name: "farewell"}
info1, err := tool1.Info(ctx)
require.NoError(t, err)
info2, err := tool2.Info(ctx)
require.NoError(t, err)
var generateCount int
cm := mockModel.NewMockToolCallingChatModel(ctrl)
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, msgs []*schema.Message, opts ...model.Option) (*schema.Message, error) {
generateCount++
if generateCount == 1 {
return schema.AssistantMessage("calling tools", []schema.ToolCall{
{ID: "tc-1", Function: schema.FunctionCall{Name: info1.Name, Arguments: `{"name": "alice"}`}},
{ID: "tc-2", Function: schema.FunctionCall{Name: info2.Name, Arguments: `{"name": "bob"}`}},
}), nil
}
return schema.AssistantMessage("done", nil), nil
}).AnyTimes()
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()
// Capture state message IDs
var stateMsgIDs map[string]string // callID -> msgID
beforeModelCount := 0
agent, err := NewChatModelAgent(ctx, &ChatModelAgentConfig{
Name: "TestMultiTool",
Instruction: "test",
Model: cm,
ToolsConfig: ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{tool1, tool2},
},
},
Middlewares: []AgentMiddleware{
{
BeforeChatModel: func(ctx context.Context, state *ChatModelAgentState) error {
beforeModelCount++
if beforeModelCount == 2 {
stateMsgIDs = make(map[string]string)
for _, m := range state.Messages {
if m.Role == schema.Tool {
stateMsgIDs[m.ToolCallID] = GetMessageID(m)
}
}
}
return nil
},
},
},
})
require.NoError(t, err)
iter := agent.Run(ctx, &AgentInput{
Messages: []Message{schema.UserMessage("use tools")},
})
events := collectEvents(t, iter)
// Expect: model(tool_calls) + tool1(result) + tool2(result) + model(final) = 4 events
require.GreaterOrEqual(t, len(events), 4)
// Collect tool event IDs
eventMsgIDs := make(map[string]string) // callID -> msgID
for _, ev := range events {
if ev.Err != nil {
continue
}
if ev.Output != nil && ev.Output.MessageOutput != nil {
msg := ev.Output.MessageOutput.Message
if msg != nil && msg.Role == schema.Tool {
eventMsgIDs[msg.ToolCallID] = GetMessageID(msg)
}
}
}
// Each tool call should have an ID in both event and state, and they must match
require.NotEmpty(t, stateMsgIDs, "state should have tool message IDs")
for callID, stateID := range stateMsgIDs {
assert.NotEmpty(t, stateID, "state msg for %s should have ID", callID)
assert.True(t, isValidUUID(stateID), "state msg ID should be UUID: %s", stateID)
eventID, ok := eventMsgIDs[callID]
assert.True(t, ok, "event should have msg for callID %s", callID)
assert.Equal(t, stateID, eventID,
"event and state msg IDs for callID %s must match: event=%s state=%s", callID, eventID, stateID)
}
}
// TestAttack_ToolResultToBlocks_EdgeCases verifies toolResultToBlocks handles
// nil ToolResult, empty Parts, and Parts with nil media fields.
func TestAttack_ToolResultToBlocks_EdgeCases(t *testing.T) {
t.Run("nil ToolResult", func(t *testing.T) {
blocks := toolResultToBlocks(nil)
assert.Nil(t, blocks, "nil ToolResult should produce nil blocks")
})
t.Run("empty Parts", func(t *testing.T) {
tr := &schema.ToolResult{Parts: []schema.ToolOutputPart{}}
blocks := toolResultToBlocks(tr)
assert.Nil(t, blocks, "empty Parts should produce nil blocks")
})
t.Run("text part with empty text", func(t *testing.T) {
tr := &schema.ToolResult{Parts: []schema.ToolOutputPart{
{Type: schema.ToolPartTypeText, Text: ""},
}}
blocks := toolResultToBlocks(tr)
require.Len(t, blocks, 1)
assert.NotNil(t, blocks[0].Text)
assert.Equal(t, "", blocks[0].Text.Text)
})
t.Run("image part with nil Image field", func(t *testing.T) {
tr := &schema.ToolResult{Parts: []schema.ToolOutputPart{
{Type: schema.ToolPartTypeImage, Image: nil},
}}
blocks := toolResultToBlocks(tr)
assert.Empty(t, blocks)
})
t.Run("audio part with nil Audio field", func(t *testing.T) {
tr := &schema.ToolResult{Parts: []schema.ToolOutputPart{
{Type: schema.ToolPartTypeAudio, Audio: nil},
}}
blocks := toolResultToBlocks(tr)
assert.Empty(t, blocks)
})
t.Run("video part with nil Video field", func(t *testing.T) {
tr := &schema.ToolResult{Parts: []schema.ToolOutputPart{
{Type: schema.ToolPartTypeVideo, Video: nil},
}}
blocks := toolResultToBlocks(tr)
assert.Empty(t, blocks)
})
t.Run("file part with nil File field", func(t *testing.T) {
tr := &schema.ToolResult{Parts: []schema.ToolOutputPart{
{Type: schema.ToolPartTypeFile, File: nil},
}}
blocks := toolResultToBlocks(tr)
assert.Empty(t, blocks)
})
t.Run("mixed: valid text + nil image + valid text", func(t *testing.T) {
tr := &schema.ToolResult{Parts: []schema.ToolOutputPart{
{Type: schema.ToolPartTypeText, Text: "hello"},
{Type: schema.ToolPartTypeImage, Image: nil},
{Type: schema.ToolPartTypeText, Text: "world"},
}}
blocks := toolResultToBlocks(tr)
require.Len(t, blocks, 2)
assert.Equal(t, "hello", blocks[0].Text.Text)
assert.Equal(t, "world", blocks[1].Text.Text)
})
t.Run("image part with nil URL pointers", func(t *testing.T) {
tr := &schema.ToolResult{Parts: []schema.ToolOutputPart{
{Type: schema.ToolPartTypeImage, Image: &schema.ToolOutputImage{
MessagePartCommon: schema.MessagePartCommon{
URL: nil,
Base64Data: nil,
MIMEType: "image/png",
},
}},
}}
blocks := toolResultToBlocks(tr)
require.Len(t, blocks, 1)
assert.NotNil(t, blocks[0].Image)
assert.Equal(t, "", blocks[0].Image.URL, "nil URL pointer should deref to empty string")
assert.Equal(t, "", blocks[0].Image.Base64Data)
assert.Equal(t, "image/png", blocks[0].Image.MIMEType)
})
}