Files
AskaEth f75e7a9c9b feat(skills): 技能加载/校验/真执行第一片(design/09 §6 定稿落地)
- 加载器/校验:目录式 configs/skills/<id>/skill.yaml(兼容旧单文件 <id>.yaml,先目录后文件);
  新字段 version/when-to-use/invocation(三态)/triggers/paths/allowed-tools/disallowed-tools/
  context(ine|fork)/agent/model/effort/metadata;**依赖环 ⇒ 加载期拒**(运行期绝不挂死);
  depends 指向不存在步骤 ⇒ 拒;同一工具同时在 allowed 与 disallowed ⇒ 拒;错误可分类(哨兵 + %w)
- Runner.Run 真执行(替换 ErrRunPending):步骤拓扑(无依赖可并行、上限 2)+ guardrail 强制点
  (enforced 违反 ⇒ 中止并如实报「哪条、哪一步」;否则 WARN 继续)+ 失败不静默跳过 + ctx 取消传播
- 🔴 能力收缩只收不放:该轮可用工具 = allowed-tools ∩ 安全闸 ∩ 注册表,再减 disallowed-tools
- 另有渐进披露相关的 listing/surface/activation(清单投影与激活判定)

⚠️ 未接线:本片是库与执行器,尚未把技能清单接进模型面(见 report 的分阶段)。
2026-10-06 09:03:35 +08:00

270 lines
8.3 KiB
Go

package skills
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
func TestStore_UpsertGet_正常(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
if err := s.Upsert(ctx, makeSkill(t, "daily-brief")); err != nil {
t.Fatalf("Upsert: %v", err)
}
got, err := s.Get(ctx, "daily-brief")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Name() != "daily-brief" {
t.Fatalf("Name = %q, want daily-brief", got.Name())
}
if got.Description() != "desc daily-brief" {
t.Fatalf("Description = %q, want desc daily-brief", got.Description())
}
if got.Prompt() != "prompt daily-brief" {
t.Fatalf("Prompt = %q, want prompt daily-brief", got.Prompt())
}
steps := got.Steps()
if len(steps) != 1 || steps[0].Name != "s1" || steps[0].Prompt != "step prompt" {
t.Fatalf("Steps = %+v, want one step s1/step prompt", steps)
}
refs := got.Refs()
if len(refs) != 1 || refs[0].Name != "r1" || refs[0].Type != "tool" || refs[0].Value != "v1" || refs[0].Optional {
t.Fatalf("Refs = %+v, want r1/tool/v1/non-optional", refs)
}
guards := got.Guardrails()
if len(guards) != 1 || guards[0].ID != "g1" || guards[0].Message != "msg" || !guards[0].Enforced {
t.Fatalf("Guardrails = %+v, want g1/msg/enforced", guards)
}
}
func TestStore_Upsert_Update_正常(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
if err := s.Upsert(ctx, makeSkill(t, "review")); err != nil {
t.Fatalf("first Upsert: %v", err)
}
// Same name, changed description/prompt — must update in place, not duplicate.
updated, err := New("review", "v2 desc", "v2 prompt", nil, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := s.Upsert(ctx, updated); err != nil {
t.Fatalf("second Upsert: %v", err)
}
got, err := s.Get(ctx, "review")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Description() != "v2 desc" || got.Prompt() != "v2 prompt" {
t.Fatalf("updated skill = %q/%q, want v2 desc/v2 prompt", got.Description(), got.Prompt())
}
all, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(all) != 1 {
t.Fatalf("List after update has %d rows, want 1", len(all))
}
}
func TestStore_Get_NotFound_异常(t *testing.T) {
t.Parallel()
s := newTestStore(t)
_, err := s.Get(context.Background(), "missing")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("Get(missing) err = %v, want ErrNotFound", err)
}
}
func TestStore_List_正常(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
for _, n := range []string{"b", "a", "c"} {
if err := s.Upsert(ctx, makeSkill(t, n)); err != nil {
t.Fatalf("Upsert(%s): %v", n, err)
}
}
got, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 3 {
t.Fatalf("List length = %d, want 3", len(got))
}
if got[0].Name() != "a" || got[1].Name() != "b" || got[2].Name() != "c" {
t.Fatalf("List order = %q,%q,%q, want a,b,c", got[0].Name(), got[1].Name(), got[2].Name())
}
}
func TestStore_Delete_正常(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
if err := s.Upsert(ctx, makeSkill(t, "x")); err != nil {
t.Fatalf("Upsert: %v", err)
}
if err := s.Delete(ctx, "x"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.Get(ctx, "x"); !errors.Is(err, ErrNotFound) {
t.Fatalf("Get after Delete err = %v, want ErrNotFound", err)
}
}
func TestStore_Delete_NotFound_异常(t *testing.T) {
t.Parallel()
s := newTestStore(t)
if err := s.Delete(context.Background(), "missing"); !errors.Is(err, ErrNotFound) {
t.Fatalf("Delete(missing) err = %v, want ErrNotFound", err)
}
}
func TestStore_Upsert_Invalid_异常(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
cases := []struct {
name string
sk sdk.Skill
}{
{name: "nil skill", sk: nil},
{name: "blank name", sk: mockSkill{name: " "}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if err := s.Upsert(ctx, tc.sk); !errors.Is(err, ErrInvalidSkill) {
t.Fatalf("Upsert(%s) err = %v, want ErrInvalidSkill", tc.name, err)
}
})
}
}
// TestStore_Spec往返_正常 断言 design/09 §6.2 的新增字段真的落盘并能原样读回 ——
// 尤其是 allowed-tools:读回来变成「不限」就等于把能力收缩放宽,那是安全红线,
// 所以这里逐字段钉死。
func TestStore_Spec往返_正常(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
skill, err := New("guarded", "d", "p", nil, nil, nil, WithSpec(Spec{
Version: 2,
WhenToUse: "何时用",
Invocation: sdk.InvocationUserOnly,
Triggers: []string{"简报"},
Paths: []string{"src/**/*.go"},
AllowedTools: []string{"read"},
DisallowedTools: []string{"write"},
Context: sdk.ContextFork,
Agent: "researcher",
Model: "fast",
Effort: "high",
Metadata: map[string]string{"owner": "yeiji"},
}))
if err != nil {
t.Fatalf("New: %v", err)
}
if err := s.Upsert(ctx, skill); err != nil {
t.Fatalf("Upsert: %v", err)
}
got, err := s.Get(ctx, "guarded")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Version() != 2 || got.WhenToUse() != "何时用" || got.Invocation() != sdk.InvocationUserOnly {
t.Fatalf("往返后 Version/WhenToUse/Invocation = %d/%q/%q", got.Version(), got.WhenToUse(), got.Invocation())
}
if len(got.AllowedTools()) != 1 || got.AllowedTools()[0] != "read" {
t.Fatalf("AllowedTools = %v, want [read](丢了它等于放宽能力)", got.AllowedTools())
}
if len(got.DisallowedTools()) != 1 || got.DisallowedTools()[0] != "write" {
t.Fatalf("DisallowedTools = %v, want [write]", got.DisallowedTools())
}
if got.ContextMode() != sdk.ContextFork || got.Agent() != "researcher" {
t.Fatalf("Context/Agent = %q/%q, want fork/researcher", got.ContextMode(), got.Agent())
}
if got.Model() != "fast" || got.Effort() != "high" {
t.Fatalf("Model/Effort = %q/%q, want fast/high", got.Model(), got.Effort())
}
if len(got.Triggers()) != 1 || len(got.Paths()) != 1 || got.Metadata()["owner"] != "yeiji" {
t.Fatalf("Triggers/Paths/Metadata 往返失败:%v/%v/%v", got.Triggers(), got.Paths(), got.Metadata())
}
}
// TestStore_基础Skill往返_缺省 断言只实现基础 sdk.Skill 的技能也能落盘,读回来是
// 文档规定的缺省值(而不是零值)。
func TestStore_基础Skill往返_缺省(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
if err := s.Upsert(ctx, mockSkill{name: "plain"}); err != nil {
t.Fatalf("Upsert: %v", err)
}
got, err := s.Get(ctx, "plain")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Version() != sdk.DefaultSkillVersion || got.Invocation() != sdk.InvocationBoth || got.ContextMode() != sdk.ContextInline {
t.Fatalf("缺省值 = %d/%q/%q, want 1/both/inline", got.Version(), got.Invocation(), got.ContextMode())
}
}
// TestStore_SQLInjection_安全 存入形似 SQL 的名字/描述,验证值被原样往返(参数化
// 语句)、表仍然完好、且不波及其它行。
func TestStore_SQLInjection_安全(t *testing.T) {
t.Parallel()
s := newTestStore(t)
ctx := context.Background()
name := "x'); DROP TABLE skills; --"
desc := "'; SELECT * FROM skills; --"
if err := s.Upsert(ctx, mockSkill{name: name}); err != nil {
t.Fatalf("Upsert injection name: %v", err)
}
got, err := s.Get(ctx, name)
if err != nil {
t.Fatalf("Get injection name: %v", err)
}
if got.Name() != name {
t.Fatalf("Name = %q, want the injection string stored verbatim", got.Name())
}
// A description carrying SQL must also be stored as-is.
sk, err := New("inject-desc", desc, "p", nil, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := s.Upsert(ctx, sk); err != nil {
t.Fatalf("Upsert injection desc: %v", err)
}
got2, err := s.Get(ctx, "inject-desc")
if err != nil {
t.Fatalf("Get inject-desc: %v", err)
}
if got2.Description() != desc {
t.Fatalf("Description = %q, want verbatim injection", got2.Description())
}
// The table must still be intact and usable.
if err := s.Upsert(ctx, makeSkill(t, "after")); err != nil {
t.Fatalf("Upsert after injection: %v", err)
}
all, err := s.List(ctx)
if err != nil {
t.Fatalf("List after injection: %v", err)
}
if len(all) != 3 {
t.Fatalf("List after injection has %d rows, want 3", len(all))
}
}