Files
CyberStrikeAI/internal/multiagent/agentic_tool_pair_reconciler_middleware_test.go
T
tempandCursor d88cfea761 fix: 为 Eino agentic 路径补充 tool_call/tool_result 配对防御中间件
agentic 路径(单代理模式)缺少 toolPairReconciler 和 orphanToolPruner,
当 summarization 截断历史破坏配对后,序列化到 OpenAI API 触发 400
"insufficient tool messages following tool_calls message"。

新增 AgenticMessage 版本的 reconciler 和 pruner,与 classic 路径对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:24:03 +08:00

158 lines
5.0 KiB
Go

package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestAgenticToolPairReconcilerPatchesMissing(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{"q":"x"}`),
agenticAssistantToolCall("c2", "execute", `{"cmd":"ls"}`),
// c1 result present, c2 missing
agenticToolResult("c1", "search", "found it"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
// Expected: assistant(c1) -> result(c1) -> assistant(c2) -> patched_result(c2)
if len(out.Messages) != 4 {
t.Fatalf("messages = %d, want 4", len(out.Messages))
}
// c1 assistant
if calls := agenticFunctionToolCalls(out.Messages[0]); len(calls) != 1 || calls[0].CallID != "c1" {
t.Fatal("msg[0] should be assistant(c1)")
}
// c1 result
if ids := agenticToolResultCallIDs(out.Messages[1]); len(ids) != 1 || ids[0] != "c1" {
t.Fatal("msg[1] should be result(c1)")
}
// c2 assistant
if calls := agenticFunctionToolCalls(out.Messages[2]); len(calls) != 1 || calls[0].CallID != "c2" {
t.Fatal("msg[2] should be assistant(c2)")
}
// c2 patched result
if ids := agenticToolResultCallIDs(out.Messages[3]); len(ids) != 1 || ids[0] != "c2" {
t.Fatal("msg[3] should be patched result(c2)")
}
resultText := out.Messages[3].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
if resultText != patchedMissingToolResult {
t.Fatalf("patched text = %q", resultText)
}
}
func TestAgenticToolPairReconcilerDropsOrphan(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
// Orphan tool result with no preceding assistant
agenticToolResult("orphan", "deleted_tool", "stale data"),
{Role: schema.AgenticRoleTypeUser, ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.UserInputText{Text: "hello"}),
}},
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 1 {
t.Fatalf("messages = %d, want 1 (orphan dropped)", len(out.Messages))
}
if out.Messages[0].ContentBlocks[0].UserInputText == nil {
t.Fatal("remaining message should be the user text")
}
}
func TestAgenticToolPairReconcilerNoopWhenPaired(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
// Should return original state unchanged
if &out.Messages[0] == &state.Messages[0] {
// pointer equality on slice — state not cloned
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
}
func TestAgenticToolPairReconcilerFixesEmptyCallID(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
CallID: "", Name: "search", Arguments: `{}`,
})},
},
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
calls := agenticFunctionToolCalls(out.Messages[0])
if len(calls) != 1 || calls[0].CallID == "" {
t.Fatalf("empty call ID should be patched, got %q", calls[0].CallID)
}
}
func TestAgenticOrphanToolPrunerRemovesOrphan(t *testing.T) {
t.Parallel()
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
// Orphan: no assistant has call_id "c_orphan"
agenticToolResult("c_orphan", "deleted", "stale"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2 (orphan pruned)", len(out.Messages))
}
}
func TestAgenticOrphanToolPrunerNoopWhenClean(t *testing.T) {
t.Parallel()
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
}