1095 lines
34 KiB
Go
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)
|
|
})
|
|
}
|