package engine // skill_wire_test.go locks the Skill.AgentType / FilePath / Version wire behavior // introduced when these three fields were pulled out of dead-field state. The // tests mirror the three branches inside invokeFork (empty / hit / miss) plus // the two payload variants (in-memory FilePath="" vs disk-loaded FilePath!="") // for skill_invoked. // // skill_wire_test.go 锁定 Skill.AgentType / FilePath / Version 从 dead-field // 挖出来后的 wire 行为. 对应 invokeFork 内三条分支 (空/命中/未命中) 以及 // skill_invoked 的两种 payload 形态 (in-memory FilePath="" vs 磁盘加载 // FilePath!=""). import ( "context" "testing" ) // ---- test doubles ---- type capturingSpawner struct { lastCfg *SubAgentConfig lastPrompt string returnMsg string } func (c *capturingSpawner) SpawnSkillAgent(_ context.Context, cfg *SubAgentConfig, prompt string) (string, error) { c.lastCfg = cfg c.lastPrompt = prompt return c.returnMsg, nil } type skillWireObserver struct { events []skillWireEvent } type skillWireEvent struct { name string data map[string]any } func (o *skillWireObserver) Event(name string, data map[string]any) { o.events = append(o.events, skillWireEvent{name: name, data: data}) } func (o *skillWireObserver) Error(_ error, _ map[string]any) {} func (o *skillWireObserver) Metric(_ string, _ float64, _ map[string]string) {} func (o *skillWireObserver) find(name string) (skillWireEvent, bool) { for _, e := range o.events { if e.name == name { return e, true } } return skillWireEvent{}, false } // ---- AgentType branches ---- // // Each sub-test below locks ONE sub-claim of the Skill.AgentType godoc // "fork 模式下使用的 Agent 类型" promise. Keeping them split prevents a // single test from silently covering for a missing sub-claim. // // 下方每个 sub-test 只锁 Skill.AgentType godoc "fork 模式下使用的 Agent // 类型" 承诺的**一条** sub-claim. 拆分防止某一条 test 把另一条的缺失掩盖掉. // (e) AllowedSubAgentTypes flows through. func TestInvokeFork_AgentType_PopulatesAllowedSubAgentTypes(t *testing.T) { spawner := &capturingSpawner{returnMsg: "ok"} r := newSkillRegistry(spawner) ar := NewAgentRegistry() if err := ar.Register(&AgentDefinition{ AgentType: "Explore", AllowedSubAgentTypes: []string{"Plan", "Verification"}, }); err != nil { t.Fatalf("Register: %v", err) } r.SetAgentRegistry(ar) r.RegisterBuiltin(&Skill{ Name: "fork-skill", Content: "run", Context: ExecutionContextFork, AgentType: "Explore", }) if _, err := r.Invoke(context.Background(), "fork-skill", "", ""); err != nil { t.Fatalf("Invoke: %v", err) } if spawner.lastCfg == nil { t.Fatal("spawner not called") } got := spawner.lastCfg.AllowedSubAgentTypes if len(got) != 2 || got[0] != "Plan" || got[1] != "Verification" { t.Errorf("AllowedSubAgentTypes: got %v, want [Plan Verification]", got) } } // (a)+(b): cfg.AllowedTools == ResolveToolset(def, parentTools). Skill itself // declares nothing, so def.AllowedTools defines the whitelist and // def.DisallowedTools removes at layer 4. func TestInvokeFork_AgentType_AppliesResolveToolsetWhenSkillSilent(t *testing.T) { spawner := &capturingSpawner{returnMsg: "ok"} r := newSkillRegistry(spawner) ar := NewAgentRegistry() if err := ar.Register(&AgentDefinition{ AgentType: "Explore", AllowedTools: []string{"Read", "Grep"}, DisallowedTools: []string{"Write"}, }); err != nil { t.Fatalf("Register: %v", err) } r.SetAgentRegistry(ar) r.SetParentToolNames(func() []string { return []string{"Read", "Grep", "Write", "Bash"} }) r.RegisterBuiltin(&Skill{ Name: "fork-skill", Content: "run", Context: ExecutionContextFork, AgentType: "Explore", // AllowedTools intentionally empty — tests the "def authoritative" branch. }) if _, err := r.Invoke(context.Background(), "fork-skill", "", ""); err != nil { t.Fatalf("Invoke: %v", err) } if spawner.lastCfg == nil { t.Fatal("spawner not called") } got := spawner.lastCfg.AllowedTools want := map[string]bool{"Read": true, "Grep": true} if !mapEq(got, want) { t.Errorf("AllowedTools: got %v, want %v (Bash dropped by def.AllowedTools whitelist, Write dropped by def.DisallowedTools)", got, want) } } // (f): skill declares its own AllowedTools. Result must be skill ∩ def. Skill // declaring Bash cannot widen past the Explore whitelist. func TestInvokeFork_AgentType_SkillToolsIntersectWithDef_NoEscape(t *testing.T) { spawner := &capturingSpawner{returnMsg: "ok"} r := newSkillRegistry(spawner) ar := NewAgentRegistry() if err := ar.Register(&AgentDefinition{ AgentType: "Explore", AllowedTools: []string{"Read", "Grep"}, }); err != nil { t.Fatalf("Register: %v", err) } r.SetAgentRegistry(ar) r.SetParentToolNames(func() []string { return []string{"Read", "Grep", "Bash"} }) r.RegisterBuiltin(&Skill{ Name: "fork-skill", Content: "run", Context: ExecutionContextFork, AgentType: "Explore", AllowedTools: []string{"Read", "Grep", "Bash"}, // skill wants Bash }) if _, err := r.Invoke(context.Background(), "fork-skill", "", ""); err != nil { t.Fatalf("Invoke: %v", err) } got := spawner.lastCfg.AllowedTools if got["Bash"] { t.Errorf("Bash must be intersected out (def restricts to Read/Grep); got %v", got) } if !got["Read"] || !got["Grep"] { t.Errorf("Read/Grep must survive intersect; got %v", got) } } // (c): skill.Model is "" → falls back to def.Model. func TestInvokeFork_AgentType_ModelFallsBackToDef(t *testing.T) { spawner := &capturingSpawner{returnMsg: "ok"} r := newSkillRegistry(spawner) ar := NewAgentRegistry() _ = ar.Register(&AgentDefinition{AgentType: "Explore", Model: "haiku"}) r.SetAgentRegistry(ar) r.SetParentToolNames(func() []string { return nil }) r.RegisterBuiltin(&Skill{ Name: "fork-skill", Content: "run", Context: ExecutionContextFork, AgentType: "Explore", // Model empty. }) if _, err := r.Invoke(context.Background(), "fork-skill", "", ""); err != nil { t.Fatalf("Invoke: %v", err) } if spawner.lastCfg.Model != "haiku" { t.Errorf("Model: got %q, want haiku (def fallback)", spawner.lastCfg.Model) } } // (c) reverse: skill.Model non-empty overrides def.Model. func TestInvokeFork_AgentType_SkillModelOverridesDef(t *testing.T) { spawner := &capturingSpawner{returnMsg: "ok"} r := newSkillRegistry(spawner) ar := NewAgentRegistry() _ = ar.Register(&AgentDefinition{AgentType: "Explore", Model: "haiku"}) r.SetAgentRegistry(ar) r.SetParentToolNames(func() []string { return nil }) r.RegisterBuiltin(&Skill{ Name: "fork-skill", Content: "run", Context: ExecutionContextFork, AgentType: "Explore", Model: "sonnet", }) if _, err := r.Invoke(context.Background(), "fork-skill", "", ""); err != nil { t.Fatalf("Invoke: %v", err) } if spawner.lastCfg.Model != "sonnet" { t.Errorf("Model: got %q, want sonnet (skill authoritative)", spawner.lastCfg.Model) } } // (d): skill.MaxTurns == 0 → falls back to def.MaxTurns (not the hard 10). func TestInvokeFork_AgentType_MaxTurnsFallsBackToDef(t *testing.T) { spawner := &capturingSpawner{returnMsg: "ok"} r := newSkillRegistry(spawner) ar := NewAgentRegistry() _ = ar.Register(&AgentDefinition{AgentType: "Explore", MaxTurns: 7}) r.SetAgentRegistry(ar) r.SetParentToolNames(func() []string { return nil }) r.RegisterBuiltin(&Skill{ Name: "fork-skill", Content: "run", Context: ExecutionContextFork, AgentType: "Explore", // MaxTurns 0. }) if _, err := r.Invoke(context.Background(), "fork-skill", "", ""); err != nil { t.Fatalf("Invoke: %v", err) } if spawner.lastCfg.MaxTurns != 7 { t.Errorf("MaxTurns: got %d, want 7 (def fallback)", spawner.lastCfg.MaxTurns) } } // (d) edge: skill.MaxTurns == 0 AND def.MaxTurns == 0 → hard default 10. func TestInvokeFork_AgentType_BothMaxTurnsZeroDefaultsTo10(t *testing.T) { spawner := &capturingSpawner{returnMsg: "ok"} r := newSkillRegistry(spawner) ar := NewAgentRegistry() _ = ar.Register(&AgentDefinition{AgentType: "Explore"}) // MaxTurns 0 r.SetAgentRegistry(ar) r.SetParentToolNames(func() []string { return nil }) r.RegisterBuiltin(&Skill{ Name: "fork-skill", Content: "run", Context: ExecutionContextFork, AgentType: "Explore", }) if _, err := r.Invoke(context.Background(), "fork-skill", "", ""); err != nil { t.Fatalf("Invoke: %v", err) } if spawner.lastCfg.MaxTurns != 10 { t.Errorf("MaxTurns: got %d, want 10 (both zero → default)", spawner.lastCfg.MaxTurns) } } func mapEq(a, b map[string]bool) bool { if len(a) != len(b) { return false } for k, v := range a { if b[k] != v { return false } } return true } func TestInvokeFork_AgentType_UnknownEmitsDiagnostic(t *testing.T) { spawner := &capturingSpawner{returnMsg: "ok"} obs := &skillWireObserver{} r := newSkillRegistry(spawner) r.SetAgentRegistry(NewAgentRegistry()) // empty registry -> guaranteed miss r.SetObserver(obs) r.RegisterBuiltin(&Skill{ Name: "fork-skill", Content: "run", Context: ExecutionContextFork, AgentType: "TypoAgent", }) if _, err := r.Invoke(context.Background(), "fork-skill", "", ""); err != nil { t.Fatalf("Invoke: %v", err) } ev, ok := obs.find("skill_fork_unknown_agent_type") if !ok { t.Fatalf("skill_fork_unknown_agent_type not emitted; events=%v", obs.events) } if ev.data["skill_name"] != "fork-skill" || ev.data["agent_type"] != "TypoAgent" { t.Errorf("event payload: got %v", ev.data) } // Fallback still spawns; AllowedSubAgentTypes stays nil (empty-equivalent branch). if spawner.lastCfg == nil { t.Fatal("expected fallback spawn, got nil cfg") } if spawner.lastCfg.AllowedSubAgentTypes != nil { t.Errorf("AllowedSubAgentTypes on miss-fallback: got %v, want nil", spawner.lastCfg.AllowedSubAgentTypes) } } func TestInvokeFork_AgentType_EmptySkipsRegistryAndNoDiagnostic(t *testing.T) { spawner := &capturingSpawner{returnMsg: "ok"} obs := &skillWireObserver{} r := newSkillRegistry(spawner) r.SetAgentRegistry(NewAgentRegistry()) r.SetObserver(obs) r.RegisterBuiltin(&Skill{ Name: "fork-skill", Content: "run", Context: ExecutionContextFork, // AgentType intentionally empty. }) if _, err := r.Invoke(context.Background(), "fork-skill", "", ""); err != nil { t.Fatalf("Invoke: %v", err) } if _, found := obs.find("skill_fork_unknown_agent_type"); found { t.Error("unexpected skill_fork_unknown_agent_type on empty AgentType") } if spawner.lastCfg == nil || spawner.lastCfg.AllowedSubAgentTypes != nil { t.Errorf("AllowedSubAgentTypes on empty branch: got %v, want nil", spawner.lastCfg.AllowedSubAgentTypes) } } // ---- skill_invoked payload ---- func TestInvoke_EmitsSkillInvokedEvent_InMemorySkill(t *testing.T) { obs := &skillWireObserver{} r := newSkillRegistry(nil) r.SetObserver(obs) // In-memory skill: FilePath left empty, Version left empty. r.RegisterBuiltin(&Skill{ Name: "mem-skill", Content: "hi $ARGUMENTS", }) if _, err := r.Invoke(context.Background(), "mem-skill", "world", ""); err != nil { t.Fatalf("Invoke: %v", err) } ev, ok := obs.find("skill_invoked") if !ok { t.Fatalf("skill_invoked not emitted; events=%v", obs.events) } // FilePath "" must be *present* in the payload, not omitted — this is the // "in-memory vs on-disk" distinction the godoc commits to. // FilePath 空串必须**存在**于 payload, 不能 omitempty -- 这是 godoc 承诺的 // "in-memory vs on-disk" 区分点. fp, has := ev.data["file_path"] if !has { t.Error("file_path key missing from skill_invoked payload") } if fp != "" { t.Errorf("file_path for in-memory skill: got %v, want empty string", fp) } if ev.data["name"] != "mem-skill" { t.Errorf("name: got %v", ev.data["name"]) } if ev.data["context_mode"] != string(ExecutionContextInline) { t.Errorf("context_mode: got %v, want inline", ev.data["context_mode"]) } if ev.data["args_len"] != len("world") { t.Errorf("args_len: got %v, want %d", ev.data["args_len"], len("world")) } } func TestInvoke_EmitsSkillInvokedEvent_FileSkillCarriesVersionAndPath(t *testing.T) { obs := &skillWireObserver{} r := newSkillRegistry(nil) r.SetObserver(obs) r.RegisterBuiltin(&Skill{ Name: "file-skill", Content: "hi", FilePath: "/tmp/skills/file-skill/SKILL.md", Version: "2.1.0", }) if _, err := r.Invoke(context.Background(), "file-skill", "", ""); err != nil { t.Fatalf("Invoke: %v", err) } ev, ok := obs.find("skill_invoked") if !ok { t.Fatalf("skill_invoked not emitted; events=%v", obs.events) } if ev.data["file_path"] != "/tmp/skills/file-skill/SKILL.md" { t.Errorf("file_path: got %v", ev.data["file_path"]) } if ev.data["version"] != "2.1.0" { t.Errorf("version: got %v", ev.data["version"]) } }