package engine import ( "context" "encoding/json" "fmt" "testing" enginecache "git.flytoex.net/yuanwei/flyto-agent/internal/cache" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // --- Fork 模式共享资源测试 --- func TestSpawnSubAgent_SharesParentResources(t *testing.T) { // 创建一个最小化的父 Engine(不需要真正的 API 连接) cfg := testConfig() cfg.Cwd = "/tmp/test" parent := &Engine{ cfg: cfg, tools: tools.NewRegistry(), observer: &NoopObserver{}, } saCfg := &SubAgentConfig{ Description: "test sub-agent", } sa := SpawnSubAgent(parent, saCfg) // 验证共享父 engine 引用 if sa.ParentEngine != parent { t.Error("子 agent 应共享父 engine 引用") } // 验证 Provider 被继承 if sa.provider == nil { t.Error("子 agent 应有 Provider") } // 验证系统提示被复制 if sa.systemPrompt == "" { // 系统提示可能为空(取决于 buildSystemPrompt 的实现) // 但 systemPrompt 字段应该被设置(即使是空字符串) // 这里不报错,只要不 panic 就行 } // 验证工具注册表被共享 if sa.toolRegistry != parent.tools { t.Error("子 agent 应共享父 engine 的工具注册表") } // 验证模型继承 if sa.Model != "test-model" { t.Errorf("子 agent 应继承父 engine 的模型,got %s", sa.Model) } // 验证 Cwd 继承 if sa.Cwd != "/tmp/test" { t.Errorf("子 agent 应继承父 engine 的 Cwd,got %s", sa.Cwd) } // 验证初始状态 if sa.Status != SubAgentStatusPending { t.Errorf("初始状态应为 pending,got %s", sa.Status) } // 验证默认 maxTurns if sa.maxTurns != 10 { t.Errorf("默认 maxTurns 应为 10,got %d", sa.maxTurns) } } func TestSpawnSubAgent_CustomConfig(t *testing.T) { cfg := testConfig() cfg.Cwd = "/tmp/test" parent := &Engine{ cfg: cfg, tools: tools.NewRegistry(), observer: &NoopObserver{}, } saCfg := &SubAgentConfig{ Description: "custom sub-agent", Model: "claude-haiku-3", AllowedTools: map[string]bool{"Read": true, "Grep": true}, MaxTurns: 20, Cwd: "/tmp/worktree", } sa := SpawnSubAgent(parent, saCfg) if sa.Model != "claude-haiku-3" { t.Errorf("应使用自定义模型,got %s", sa.Model) } if sa.Cwd != "/tmp/worktree" { t.Errorf("应使用自定义 Cwd,got %s", sa.Cwd) } if sa.maxTurns != 20 { t.Errorf("应使用自定义 maxTurns,got %d", sa.maxTurns) } if !sa.allowedTools["Read"] || !sa.allowedTools["Grep"] { t.Error("allowedTools 应包含 Read 和 Grep") } } // --- canUseTool 过滤测试 --- func TestSubAgent_CanUseTool_NilAllowsAll(t *testing.T) { sa := &SubAgent{ allowedTools: nil, // nil = 不限制 } // nil 时允许大多数工具 if !sa.canUseTool("Read", nil) { t.Error("nil allowedTools 应允许 Read") } if !sa.canUseTool("Bash", nil) { t.Error("nil allowedTools 应允许 Bash") } // 但始终排除 Agent(防止递归) if sa.canUseTool("Agent", nil) { t.Error("即使 allowedTools 为 nil,也应排除 Agent 工具") } } func TestSubAgent_CanUseTool_WhitelistMode(t *testing.T) { sa := &SubAgent{ allowedTools: map[string]bool{ "Read": true, "Grep": true, "Glob": true, }, } // 白名单内的工具应允许 if !sa.canUseTool("Read", nil) { t.Error("白名单应允许 Read") } if !sa.canUseTool("Grep", nil) { t.Error("白名单应允许 Grep") } // 白名单外的工具应拒绝 if sa.canUseTool("Bash", nil) { t.Error("白名单外应拒绝 Bash") } if sa.canUseTool("Agent", nil) { t.Error("白名单外应拒绝 Agent") } if sa.canUseTool("Edit", nil) { t.Error("白名单外应拒绝 Edit") } } func TestSubAgent_CanUseTool_AgentAutoExcluded(t *testing.T) { // 即使在 AllowedTools 中传入 Agent,SpawnSubAgent 会自动删除 parent := &Engine{ cfg: testConfig(), tools: tools.NewRegistry(), observer: &NoopObserver{}, } cfg := &SubAgentConfig{ AllowedTools: map[string]bool{ "Read": true, "Agent": true, // 显式传入 Agent }, } sa := SpawnSubAgent(parent, cfg) // Agent 应被自动移除 if sa.canUseTool("Agent", nil) { t.Error("Agent 工具应被自动从 AllowedTools 中移除") } // Read 应保留 if !sa.canUseTool("Read", nil) { t.Error("Read 应保留在 AllowedTools 中") } } // --- ID 唯一性测试 --- func TestNextSubAgentID_Unique(t *testing.T) { ids := make(map[string]bool) for i := 0; i < 100; i++ { id := nextSubAgentID() if ids[id] { t.Fatalf("ID 重复: %s", id) } ids[id] = true } } // --- ToolsToAllowedMap 测试 --- func TestToolsToAllowedMap_Empty(t *testing.T) { result := ToolsToAllowedMap(nil) if result != nil { t.Error("空工具列表应返回 nil(不限制)") } } func TestToolsToAllowedMap_WithTools(t *testing.T) { // 使用 FilterSubAgentTools 的输入格式测试 toolList := []tools.Tool{ &mockTool{name: "Read"}, &mockTool{name: "Grep"}, &mockTool{name: "Bash"}, } result := ToolsToAllowedMap(toolList) if result == nil { t.Fatal("非空工具列表应返回非 nil map") } if !result["Read"] { t.Error("应包含 Read") } if !result["Grep"] { t.Error("应包含 Grep") } if !result["Bash"] { t.Error("应包含 Bash") } if result["Agent"] { t.Error("不应包含 Agent") } } // --- FilterSubAgentTools 向后兼容测试 --- func TestFilterSubAgentTools_ExcludesAgent(t *testing.T) { allTools := []tools.Tool{ &mockTool{name: "Read"}, &mockTool{name: "Grep"}, &mockTool{name: "Agent"}, &mockTool{name: "Bash"}, } filtered := FilterSubAgentTools(allTools) for _, t2 := range filtered { if t2.Name() == "Agent" { t.Error("FilterSubAgentTools 不应包含 Agent 工具") } } if len(filtered) != 3 { t.Errorf("过滤后应有 3 个工具,got %d", len(filtered)) } } // --- 全工具列表用于 cache key 一致性测试 --- func TestSpawnSubAgent_AllToolDefsFromParent(t *testing.T) { parent := &Engine{ cfg: testConfig(), tools: tools.NewRegistry(), observer: &NoopObserver{}, } // 注册一些工具 _ = parent.tools.Register(&mockTool{name: "Read"}) _ = parent.tools.Register(&mockTool{name: "Grep"}) _ = parent.tools.Register(&mockTool{name: "Agent"}) // 创建子 agent,只允许 Read sa := SpawnSubAgent(parent, &SubAgentConfig{ AllowedTools: map[string]bool{"Read": true}, }) // 精妙之处验证:allToolDefs 应包含完整的工具列表(包括 Agent) // 这样 cache key 和父 engine 一致 if len(sa.allToolDefs) != 3 { t.Errorf("allToolDefs 应包含所有 %d 个工具(cache key 一致),got %d", 3, len(sa.allToolDefs)) } // 但运行时 canUseTool 只允许 Read if !sa.canUseTool("Read", nil) { t.Error("canUseTool 应允许 Read") } if sa.canUseTool("Grep", nil) { t.Error("canUseTool 应拒绝 Grep(不在白名单中)") } if sa.canUseTool("Agent", nil) { t.Error("canUseTool 应拒绝 Agent(不在白名单中)") } } // --- SubAgentProgress 测试 --- func TestSubAgentProgress_AddActivity(t *testing.T) { p := &SubAgentProgress{} for i := 0; i < 10; i++ { p.AddActivity(fmt.Sprintf("activity_%d", i)) } snap := p.Snapshot() if len(snap.Activities) != 5 { t.Errorf("应只保留最近 5 条活动记录,got %d", len(snap.Activities)) } if snap.Activities[0] != "activity_5" { t.Errorf("最早的活动应为 activity_5,got %s", snap.Activities[0]) } } // --- SubAgentEvent 包装测试 --- func TestSubAgentEvent_Type(t *testing.T) { evt := &SubAgentEvent{ SubAgentID: "test-123", Inner: &TextEvent{Text: "hello"}, } if evt.EventType() != "subagent_text" { t.Errorf("eventType 应为 subagent_text,got %s", evt.EventType()) } } // --- 13.3 cache key 匹配测试 --- // TestSpawnSubAgent_UsesSnapshotNotBuildToolDefs 验证 SpawnSubAgent 读取缓存快照 // 而非重调 buildToolDefs(即不推进 toolSchemaTracker 的 turnCount). // // 精妙之处(CLEVER): turnCount 是 toolSchemaTracker 的核心状态-- // 稳定性判断(isStableLocked)依赖 turnCount,多余的 Track() 调用会导致 // stableCount 偏移,cache_control 打到错误位置,破坏 Anthropic Prompt Cache 命中. func TestSpawnSubAgent_UsesSnapshotNotBuildToolDefs(t *testing.T) { eng := newTestEngineWithTracker(t) // 模拟"本轮查询已经调用过 buildToolDefs":手动触发一次,作为基准 turnCount. _ = eng.buildToolDefs(context.Background()) turnAfterBuild := eng.toolSchemaTracker.TurnCount() // SpawnSubAgent 不应再次调用 buildToolDefs(不应推进 turnCount) _ = SpawnSubAgent(eng, &SubAgentConfig{}) turnAfterSpawn := eng.toolSchemaTracker.TurnCount() if turnAfterSpawn != turnAfterBuild { t.Errorf("SpawnSubAgent 不应推进 toolSchemaTracker.turnCount:"+ "buildToolDefs 后=%d,SpawnSubAgent 后=%d(多了 %d 次 Track 调用)", turnAfterBuild, turnAfterSpawn, turnAfterSpawn-turnAfterBuild) } } // TestSpawnSubAgent_CacheKeyConsistency 验证子代理拿到的 allToolDefs 与 // 父 engine 本轮 buildToolDefs 返回值完全一致(包括 cache_control 位置). // // 场景:EnableCaching=true,工具列表经过足够轮次已产生稳定工具, // buildToolDefs 在最后一个稳定工具上打了 cache_control. // 子代理必须拿到同样的 cache_control,才能命中父 engine 建立的缓存条目. func TestSpawnSubAgent_CacheKeyConsistency(t *testing.T) { parent := newTestEngineWithTracker(t) parent.cfg.EnableCaching = true // 连续 Track 足够轮次让工具进入稳定状态(窗口默认 5) for i := 0; i < 6; i++ { _ = parent.buildToolDefs(context.Background()) } // 本轮计算的工具定义(含 cache_control) parentDefs := parent.buildToolDefs(context.Background()) // 子代理应读取完全相同的快照 sa := SpawnSubAgent(parent, &SubAgentConfig{}) if len(sa.allToolDefs) != len(parentDefs) { t.Fatalf("allToolDefs 长度不一致:parent=%d,subagent=%d", len(parentDefs), len(sa.allToolDefs)) } for i, pd := range parentDefs { sd := sa.allToolDefs[i] if pd.Name != sd.Name { t.Errorf("[%d] Name 不一致:parent=%q subagent=%q", i, pd.Name, sd.Name) } // 验证 cache_control 位置完全一致(cache key 的关键字段) parentHasCC := pd.CacheControl != nil subHasCC := sd.CacheControl != nil if parentHasCC != subHasCC { t.Errorf("[%d] %q:cache_control 不一致 parent=%v subagent=%v", i, pd.Name, parentHasCC, subHasCC) } if parentHasCC && subHasCC && pd.CacheControl.Type != sd.CacheControl.Type { t.Errorf("[%d] %q:cache_control.Type 不一致 parent=%q subagent=%q", i, pd.Name, pd.CacheControl.Type, sd.CacheControl.Type) } } } // newTestEngineWithTracker 构建一个带 toolSchemaTracker 的最小化测试 Engine. // 这类引擎只用于测试 toolSchemaTracker 相关逻辑,不连接真实 API. func newTestEngineWithTracker(t *testing.T) *Engine { t.Helper() eng := &Engine{ cfg: testConfig(), tools: tools.NewRegistry(), observer: &NoopObserver{}, toolSchemaTracker: enginecache.NewToolSchemaTracker(), } _ = eng.tools.Register(&mockTool{name: "Read"}) _ = eng.tools.Register(&mockTool{name: "Grep"}) _ = eng.tools.Register(&mockTool{name: "Bash"}) return eng } // --- P1-A: AllowedSubAgentTypes 测试 --- func TestSubAgent_AllowedSubAgentTypes_NoRestriction(t *testing.T) { // AllowedSubAgentTypes 为 nil 时,不附加子 agent 类型检查-- // Agent 工具是否可用取决于 allowedTools(nil 时 Agent 被防递归逻辑阻断). // // 场景:allowedTools 显式包含 "Agent"(如三层过滤后 resolveAgentToolset 保留了它), // allowedSubAgentTypes = nil → 不限制类型,任何 agent_type 都可 spawn. sa := &SubAgent{ allowedTools: map[string]bool{ "Agent": true, // 显式允许 Agent 工具(绕过防递归拦截) }, allowedSubAgentTypes: nil, // nil = 不限制 agent 类型 } input, _ := json.Marshal(map[string]string{"agent_type": "general-purpose", "prompt": "test"}) if !sa.canUseTool("Agent", input) { t.Error("AllowedSubAgentTypes 为 nil 时应允许任意 agent_type(包括 general-purpose)") } input2, _ := json.Marshal(map[string]string{"agent_type": "Explore", "prompt": "test"}) if !sa.canUseTool("Agent", input2) { t.Error("AllowedSubAgentTypes 为 nil 时应允许任意 agent_type(包括 Explore)") } } func TestSubAgent_AllowedSubAgentTypes_RestrictedAllow(t *testing.T) { // AllowedSubAgentTypes 有值时,只允许列表中的类型 sa := &SubAgent{ allowedTools: map[string]bool{"Agent": true}, // Agent 工具本身被允许 allowedSubAgentTypes: map[string]struct{}{ "Explore": {}, "Plan": {}, }, } input, _ := json.Marshal(map[string]string{"agent_type": "Explore"}) if !sa.canUseTool("Agent", input) { t.Error("Explore 在 AllowedSubAgentTypes 中,应允许") } input2, _ := json.Marshal(map[string]string{"agent_type": "Plan"}) if !sa.canUseTool("Agent", input2) { t.Error("Plan 在 AllowedSubAgentTypes 中,应允许") } } func TestSubAgent_AllowedSubAgentTypes_RestrictedBlock(t *testing.T) { // AllowedSubAgentTypes 不包含的类型应被拒绝 sa := &SubAgent{ allowedTools: map[string]bool{"Agent": true}, allowedSubAgentTypes: map[string]struct{}{ "Explore": {}, }, } // general-purpose 不在列表中 input, _ := json.Marshal(map[string]string{"agent_type": "general-purpose"}) if sa.canUseTool("Agent", input) { t.Error("general-purpose 不在 AllowedSubAgentTypes 中,应拒绝") } // Verification 也不在列表中 input2, _ := json.Marshal(map[string]string{"agent_type": "Verification"}) if sa.canUseTool("Agent", input2) { t.Error("Verification 不在 AllowedSubAgentTypes 中,应拒绝") } } func TestSubAgent_AllowedSubAgentTypes_EmptyTypeDefaultsToGeneralPurpose(t *testing.T) { // agent_type 为空时视为 general-purpose sa := &SubAgent{ allowedTools: map[string]bool{"Agent": true}, allowedSubAgentTypes: map[string]struct{}{ "general-purpose": {}, }, } // agent_type 为空 → 默认 general-purpose → 在列表中 → 允许 input, _ := json.Marshal(map[string]string{"prompt": "test"}) // 无 agent_type 字段 if !sa.canUseTool("Agent", input) { t.Error("agent_type 为空时应默认为 general-purpose,在列表中应允许") } } func TestSubAgent_AllowedSubAgentTypes_InvalidJSON(t *testing.T) { // rawInput 无法解析时拒绝 sa := &SubAgent{ allowedTools: map[string]bool{"Agent": true}, allowedSubAgentTypes: map[string]struct{}{ "Explore": {}, }, } if sa.canUseTool("Agent", json.RawMessage("not-valid-json")) { t.Error("无效 JSON 输入应拒绝") } } func TestSpawnSubAgent_AllowedSubAgentTypes_Conversion(t *testing.T) { // SpawnSubAgent 应将 []string 转为 map[string]struct{} parent := &Engine{ cfg: testConfig(), tools: tools.NewRegistry(), observer: &NoopObserver{}, } cfg := &SubAgentConfig{ AllowedTools: map[string]bool{"Agent": true}, AllowedSubAgentTypes: []string{"Explore", "Plan"}, } sa := SpawnSubAgent(parent, cfg) // 验证转换结果 if sa.allowedSubAgentTypes == nil { t.Fatal("allowedSubAgentTypes 应非 nil") } if _, ok := sa.allowedSubAgentTypes["Explore"]; !ok { t.Error("Explore 应在 allowedSubAgentTypes 中") } if _, ok := sa.allowedSubAgentTypes["Plan"]; !ok { t.Error("Plan 应在 allowedSubAgentTypes 中") } if _, ok := sa.allowedSubAgentTypes["general-purpose"]; ok { t.Error("general-purpose 不应在 allowedSubAgentTypes 中") } } // --- 辅助类型 --- // mockTool 是测试用的模拟工具 type mockTool struct { name string } func (m *mockTool) Name() string { return m.name } func (m *mockTool) Description(_ context.Context) string { return m.name + " tool" } func (m *mockTool) InputSchema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } func (m *mockTool) Execute(_ context.Context, _ json.RawMessage, _ tools.ProgressFunc) (*tools.Result, error) { return &tools.Result{Output: "ok"}, nil }