package skills import ( "context" "errors" "strings" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) func TestRunner_Expand_正常(t *testing.T) { t.Parallel() r := NewRunner(registry.New(), testLogger()) wf, err := r.Expand(context.Background(), makeSkill(t, "review")) if err != nil { t.Fatalf("Expand: %v", err) } if wf.Name != "review" { t.Fatalf("Name = %q, want review", wf.Name) } if wf.Description != "desc review" { t.Fatalf("Description = %q, want desc review", wf.Description) } if wf.Prompt != "prompt review" { t.Fatalf("Prompt = %q, want prompt review", wf.Prompt) } if len(wf.Steps) != 1 || wf.Steps[0].Name != "s1" { t.Fatalf("Steps = %+v, want one step s1", wf.Steps) } if len(wf.Refs) != 1 || wf.Refs[0].Name != "r1" { t.Fatalf("Refs = %+v, want one ref r1", wf.Refs) } if len(wf.Guardrails) != 1 || wf.Guardrails[0].ID != "g1" { t.Fatalf("Guardrails = %+v, want one guardrail g1", wf.Guardrails) } if wf.Spec.Version != sdk.DefaultSkillVersion || wf.Spec.Invocation != sdk.InvocationBoth || wf.Spec.Context != sdk.ContextInline { t.Fatalf("Spec 缺省值 = %+v, want version=1/invocation=both/context=inline", wf.Spec) } } func TestRunner_Expand_Nil_异常(t *testing.T) { t.Parallel() r := NewRunner(registry.New(), testLogger()) if _, err := r.Expand(context.Background(), nil); !errors.Is(err, ErrInvalidSkill) { t.Fatalf("Expand(nil) err = %v, want ErrInvalidSkill", err) } } func TestRunner_Expand_BlankName_异常(t *testing.T) { t.Parallel() r := NewRunner(registry.New(), testLogger()) if _, err := r.Expand(context.Background(), mockSkill{name: " "}); !errors.Is(err, ErrInvalidSkill) { t.Fatalf("Expand(blank) err = %v, want ErrInvalidSkill", err) } } func TestRunner_Expand_ContextCanceled_异常(t *testing.T) { t.Parallel() r := NewRunner(registry.New(), testLogger()) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := r.Expand(ctx, makeSkill(t, "review")); !errors.Is(err, context.Canceled) { t.Fatalf("Expand(canceled) err = %v, want context.Canceled", err) } } func TestRunner_ExpandByName_正常(t *testing.T) { t.Parallel() reg := registry.New() if err := Register(reg, makeSkill(t, "review")); err != nil { t.Fatalf("Register: %v", err) } r := NewRunner(reg, testLogger()) wf, err := r.ExpandByName(context.Background(), "review") if err != nil { t.Fatalf("ExpandByName: %v", err) } if wf.Name != "review" || len(wf.Steps) != 1 { t.Fatalf("Workflow = %+v, want review with one step", wf) } } func TestRunner_ExpandByName_NotSkill_异常(t *testing.T) { t.Parallel() reg := registry.New() // 注册一个 Value 不是 Skill 的能力(直接用裸 API)。 if err := reg.Register(registry.Capability{Name: "notaskill", Kind: registry.KindTool, Value: "x"}); err != nil { t.Fatalf("register tool-cap: %v", err) } r := NewRunner(reg, testLogger()) if _, err := r.ExpandByName(context.Background(), "notaskill"); !errors.Is(err, ErrNotFound) { t.Fatalf("ExpandByName(non-skill) err = %v, want ErrNotFound", err) } } func TestRunner_ExpandByName_NotFound_异常(t *testing.T) { t.Parallel() r := NewRunner(registry.New(), testLogger()) if _, err := r.ExpandByName(context.Background(), "missing"); !errors.Is(err, registry.ErrNotFound) { t.Fatalf("ExpandByName(missing) err = %v, want registry.ErrNotFound", err) } } func TestRunner_ExpandByName_NilRegistrar_异常(t *testing.T) { t.Parallel() r := NewRunner(nil, testLogger()) if _, err := r.ExpandByName(context.Background(), "x"); !errors.Is(err, ErrNilRegistrar) { t.Fatalf("ExpandByName(nil reg) err = %v, want ErrNilRegistrar", err) } } // ---- Run / RunSkill:真执行 ---- // TestRunner_RunSkill_线性依赖按序执行 覆盖最基本的执行路径:一步依赖另一步, // 前序输出按步骤名传给下游,结果按声明序汇报。 func TestRunner_RunSkill_线性依赖按序执行(t *testing.T) { t.Parallel() skill := mustSkill(t, "brief", []sdk.Step{ {Name: "collect", Prompt: "收集"}, {Name: "summarize", Prompt: "汇总", Depends: []string{"collect"}}, }, WithSpec(Spec{AllowedTools: []string{"search", "note"}})) exec := &recordingExecutor{outputs: map[string]string{"collect": "原料", "summarize": "结论"}} res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{ Executor: exec, Gate: NewNameSet([]string{"search", "note", "write"}), ToolNames: []string{"search", "note", "write"}, }) if err != nil { t.Fatalf("RunSkill: %v", err) } if got := strings.Join(exec.order(), ","); got != "collect,summarize" { t.Fatalf("执行顺序 = %q, want collect,summarize", got) } if got := exec.depsOf("summarize")["collect"]; got != "原料" { t.Fatalf("summarize 拿到的 collect 输出 = %q, want 原料", got) } if got := strings.Join(res.Tools, ","); got != "search,note" { t.Fatalf("Tools = %q, want search,note(write 被 allowed-tools 收掉)", got) } if len(res.Steps) != 2 || res.Steps[0].Name != "collect" || res.Steps[1].Output != "结论" { t.Fatalf("Steps = %+v, want collect/summarize 两条", res.Steps) } } // TestRunner_RunSkill_每步工具面一致且已收缩 断言 executor 看到的 Tools 就是 // 收缩后的结果(执行侧不可能拿到更宽的面)。 func TestRunner_RunSkill_每步工具面一致且已收缩(t *testing.T) { t.Parallel() skill := mustSkill(t, "s", []sdk.Step{{Name: "one", Prompt: "p"}}, WithSpec(Spec{AllowedTools: []string{"a"}, DisallowedTools: []string{"a2"}})) exec := &recordingExecutor{} _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{ Executor: exec, Gate: NewNameSet([]string{"a", "b"}), ToolNames: []string{"a", "a2", "b"}, }) if err != nil { t.Fatalf("RunSkill: %v", err) } if got := strings.Join(exec.toolsOf("one"), ","); got != "a" { t.Fatalf("步骤工具面 = %q, want a", got) } } func TestRunner_RunSkill_校验失败_异常(t *testing.T) { t.Parallel() // 依赖成环 ⇒ 执行前就被 Validate 拦下(运行期绝不挂死)。 skill := mustSkill(t, "cycle", []sdk.Step{ {Name: "a", Prompt: "p", Depends: []string{"b"}}, {Name: "b", Prompt: "p", Depends: []string{"a"}}, }) _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{ Executor: &recordingExecutor{}, Gate: NewNameSet(nil), ToolNames: []string{"x"}, }) if !errors.Is(err, ErrStepCycle) { t.Fatalf("RunSkill(cycle) err = %v, want ErrStepCycle", err) } } func TestRunner_RunSkill_缺执行器_异常(t *testing.T) { t.Parallel() _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), makeSkill(t, "s"), RunOptions{ Gate: NewNameSet(nil), }) if !errors.Is(err, ErrNoExecutor) { t.Fatalf("RunSkill 无 executor err = %v, want ErrNoExecutor", err) } } func TestRunner_RunSkill_缺安全闸_异常(t *testing.T) { t.Parallel() _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), makeSkill(t, "s"), RunOptions{ Executor: &recordingExecutor{}, }) if !errors.Is(err, ErrNilGate) { t.Fatalf("RunSkill 无 gate err = %v, want ErrNilGate", err) } } func TestRunner_RunSkill_Nil_异常(t *testing.T) { t.Parallel() _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), nil, RunOptions{ Executor: &recordingExecutor{}, Gate: NewNameSet(nil), }) if !errors.Is(err, ErrInvalidSkill) { t.Fatalf("RunSkill(nil) err = %v, want ErrInvalidSkill", err) } } // TestRunner_Run_按注册表找技能 覆盖 Run 的按名查找路径(含找不到的两种情形)。 func TestRunner_Run_按注册表找技能(t *testing.T) { t.Parallel() reg := registry.New() skill := mustSkill(t, "brief", []sdk.Step{{Name: "one", Prompt: "p"}}) if err := Register(reg, skill); err != nil { t.Fatalf("Register: %v", err) } r := NewRunner(reg, testLogger()) res, err := r.Run(context.Background(), "brief", RunOptions{ Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, }) if err != nil { t.Fatalf("Run: %v", err) } if res.Skill != "brief" || len(res.Steps) != 1 { t.Fatalf("RunResult = %+v, want brief with one step", res) } if _, err := r.Run(context.Background(), "missing", RunOptions{ Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, }); !errors.Is(err, registry.ErrNotFound) { t.Fatalf("Run(missing) err = %v, want registry.ErrNotFound", err) } if _, err := NewRunner(nil, testLogger()).Run(context.Background(), "brief", RunOptions{ Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), }); !errors.Is(err, ErrNilRegistrar) { t.Fatalf("Run(nil reg) err = %v, want ErrNilRegistrar", err) } } // TestRunner_RunSkill_工具面取自注册表 断言 ToolNames 缺省时从注册表 KindTool 读, // 且技能声明的工具名必须真实存在于注册表 —— 不存在的名字不会凭空出现。 func TestRunner_RunSkill_工具面取自注册表(t *testing.T) { t.Parallel() reg := registry.New() for _, name := range []string{"alpha", "beta"} { sdkTool := stubTool{name: name} if err := reg.RegisterTool(sdkTool); err != nil { t.Fatalf("RegisterTool(%s): %v", name, err) } } skill := mustSkill(t, "s", []sdk.Step{{Name: "one", Prompt: "p"}}, WithSpec(Spec{AllowedTools: []string{"alpha", "ghost"}})) exec := &recordingExecutor{} res, err := NewRunner(reg, testLogger()).RunSkill(context.Background(), skill, RunOptions{ Executor: exec, Gate: NewNameSet([]string{"alpha", "beta"}), }) if err != nil { t.Fatalf("RunSkill: %v", err) } if got := strings.Join(res.Tools, ","); got != "alpha" { t.Fatalf("Tools = %q, want alpha(beta 被技能收掉,ghost 不在注册表里)", got) } } // stubTool 是注册表测试用的最小 sdk.Tool。 type stubTool struct{ name string } func (s stubTool) Name() string { return s.name } func (s stubTool) Description() string { return "stub " + s.name } func (s stubTool) Parameters() (map[string]any, error) { return map[string]any{"type": "object"}, nil } func (s stubTool) Execute(_ context.Context, _ any) (any, error) { return nil, nil } // mustSkill 构造一个技能,失败即 fatal。 func mustSkill(t *testing.T, name string, steps []sdk.Step, opts ...Option) *Skill { t.Helper() skill, err := New(name, "desc "+name, "base prompt", steps, nil, nil, opts...) if err != nil { t.Fatalf("New(%s): %v", name, err) } return skill } // recordingExecutor 记录每一步的执行顺序与入参,并可按步骤名定制输出/错误。 type recordingExecutor struct { mu sync.Mutex steps []string deps map[string]map[string]string tools map[string][]string outputs map[string]string errs map[string]error } func (e *recordingExecutor) ExecuteStep(_ context.Context, req StepRequest) (StepResult, error) { e.mu.Lock() defer e.mu.Unlock() if e.deps == nil { e.deps = make(map[string]map[string]string) } if e.tools == nil { e.tools = make(map[string][]string) } e.steps = append(e.steps, req.Step.Name) e.deps[req.Step.Name] = req.Depends e.tools[req.Step.Name] = append([]string(nil), req.Tools...) if err := e.errs[req.Step.Name]; err != nil { return StepResult{}, err } return StepResult{Output: e.outputs[req.Step.Name]}, nil } func (e *recordingExecutor) order() []string { e.mu.Lock() defer e.mu.Unlock() return append([]string(nil), e.steps...) } func (e *recordingExecutor) depsOf(step string) map[string]string { e.mu.Lock() defer e.mu.Unlock() return e.deps[step] } func (e *recordingExecutor) toolsOf(step string) []string { e.mu.Lock() defer e.mu.Unlock() return append([]string(nil), e.tools[step]...) }