package registry import ( "context" "errors" "testing" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件钉住切片 W3 起的**窄方法** `ReplaceTool`(热替换,design/09 §5.5): // 面板保存后重建出来的同名工具要能就地换掉旧条目,且换完不丢「用户改过的策略」。 // // 单独成文件的理由:registry_test.go 在 HEAD 上就没过 gofmt(存量), // 本片不动它(不顺手格式化会产生与主题无关的 diff)。 // stubTool 是一个名字可指定的最小工具(只用于注册/替换的判定)。 type stubTool struct { name string desc string } func (s stubTool) Name() string { return s.name } func (s stubTool) Description() string { return s.desc } 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 } var _ sdk.Tool = (*stubTool)(nil) // TestReplaceToolSwapsValueAndKeepsPolicy 是热替换的主判据: // - Value 换成新工具(调用方随后 Invoke 拿到的就是新的那一把); // - Description 跟着新工具走(它是工具实现的事实); // - **Policy 原样保留** —— 分组/陌生人可达标记是运行期配置,不属于工具实现; // 丢掉它 = 一次面板保存让某个群忽然不能用了。 func TestReplaceToolSwapsValueAndKeepsPolicy(t *testing.T) { r := New() if err := r.RegisterTool(&stubTool{name: "web-search", desc: "old"}); err != nil { t.Fatalf("register: %v", err) } if err := r.SetAllowInGroup("web-search", true); err != nil { t.Fatalf("SetAllowInGroup: %v", err) } next := &stubTool{name: "web-search", desc: "new"} if err := r.ReplaceTool(next); err != nil { t.Fatalf("ReplaceTool: %v", err) } cap, err := r.Get("web-search") if err != nil { t.Fatalf("Get: %v", err) } if cap.Value != sdk.Tool(next) { t.Fatalf("替换后 Value = %#v, want 新的那一把", cap.Value) } if cap.Description != "new" { t.Fatalf("替换后 Description = %q, want %q(描述属于工具实现,跟着换)", cap.Description, "new") } if !cap.Policy.AllowInGroup { t.Fatal("替换丢掉了 AllowInGroup:运行期策略不属于工具构造产出,必须沿用") } if cap.Kind != KindTool { t.Fatalf("替换后 Kind = %q, want %q", cap.Kind, KindTool) } } // TestReplaceToolReportsMissingAndInvalid:两种**不该被静默吞掉**的调用错误。 func TestReplaceToolReportsMissingAndInvalid(t *testing.T) { r := New() err := r.ReplaceTool(&stubTool{name: "web-search"}) if !errors.Is(err, ErrNotFound) { t.Fatalf("替换不存在的名字 err = %v, want ErrNotFound(不顺手新增:名字打错时静默插入会掩盖调用方的错)", err) } if err := r.RegisterTool(&stubTool{name: "nope"}); err != nil { t.Fatalf("register: %v", err) } if err := r.ReplaceTool(nil); !errors.Is(err, ErrInvalidArgument) { t.Fatalf("替换成 nil err = %v, want ErrInvalidArgument", err) } } // TestReplaceToolRefusesNonToolEntry:同名条目不是工具时拒绝,绝不悄悄改掉它的 Kind // (把一条渠道/MCP 能力换成工具会让「这条能力是什么」变成一件靠时间猜的事)。 func TestReplaceToolRefusesNonToolEntry(t *testing.T) { r := New() if err := r.Register(Capability{Name: "web-search", Kind: KindMCP, Value: struct{}{}}); err != nil { t.Fatalf("register: %v", err) } err := r.ReplaceTool(&stubTool{name: "web-search"}) if !errors.Is(err, ErrInvalidArgument) { t.Fatalf("err = %v, want ErrInvalidArgument", err) } cap, gerr := r.Get("web-search") if gerr != nil { t.Fatalf("Get: %v", gerr) } if cap.Kind != KindMCP { t.Fatalf("被拒的替换却改了 Kind:%q", cap.Kind) } }