- 引入 github.com/santhosh-tekuri/jsonschema/v5(纯 Go,OpenClaw/MCP SDK 同思路)。 - validate.go 重写:validateArgs 用 jsonschema Compile+Validate 替换手写子集(required单层), 支持 required/properties/嵌套 object/array items/additionalProperties/allOf/anyOf/oneOf/ 类型联合/enum/min-max/pattern/defaults;编译器缓存 sync.Map;Draft7。 - coerce.go:对齐 OpenClaw coerceWithJsonSchema 类型强转(string→number/integer/boolean/ array/object,number/bool→string;union 处理;object/array 递归)。 - defaults.go:applyDefaults 缺失属性按 default 填(含嵌套),先填默认再强转再校验。 - 错误路径:ValidationError.InstanceLocation → 带路径报告(/count: expected integer...)。 - 测试:tool_test.go 更新 required 语义 + 补强转/default/错误路径用例。 go build/vet/test 全绿 + windows/darwin 交叉编译 + gofmt。
467 lines
15 KiB
Go
467 lines
15 KiB
Go
package tool
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
|
)
|
|
|
|
// mockTool is a configurable sdk.Tool for dispatcher tests.
|
|
type mockTool struct {
|
|
name string
|
|
desc string
|
|
params map[string]any
|
|
paramsErr error
|
|
execFn func(ctx context.Context, args any) (any, error)
|
|
}
|
|
|
|
func (m mockTool) Name() string { return m.name }
|
|
func (m mockTool) Description() string { return m.desc }
|
|
func (m mockTool) Parameters() (map[string]any, error) { return m.params, m.paramsErr }
|
|
func (m mockTool) Execute(ctx context.Context, args any) (any, error) {
|
|
if m.execFn == nil {
|
|
return args, nil
|
|
}
|
|
return m.execFn(ctx, args)
|
|
}
|
|
|
|
// newDispatcher builds a Dispatcher backed by a fresh registry and returns the
|
|
// registry so tests can register non-tool fixtures.
|
|
func newDispatcher(t *testing.T, opts ...Option) (*Dispatcher, *registry.Registry) {
|
|
t.Helper()
|
|
reg := registry.New()
|
|
d, err := New(reg, opts...)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
return d, reg
|
|
}
|
|
|
|
func TestNew_NilRegistry(t *testing.T) {
|
|
t.Parallel()
|
|
if _, err := New(nil); !errors.Is(err, ErrNilRegistry) {
|
|
t.Fatalf("New(nil) error = %v, want ErrNilRegistry", err)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_Success(t *testing.T) {
|
|
t.Parallel()
|
|
d, reg := newDispatcher(t)
|
|
params := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{"q": map[string]any{"type": "string"}},
|
|
"required": []any{"q"},
|
|
}
|
|
tool := mockTool{name: "echo", params: params, execFn: func(_ context.Context, args any) (any, error) {
|
|
return args, nil
|
|
}}
|
|
if err := d.Register(tool); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
|
|
cap, err := reg.Get("echo")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if cap.Kind != registry.KindTool {
|
|
t.Fatalf("kind = %q, want tool", cap.Kind)
|
|
}
|
|
|
|
got, err := d.Invoke(context.Background(), "echo", map[string]any{"q": "hi"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
want := map[string]any{"q": "hi"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("Invoke result = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRegister_Duplicate(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
if err := d.Register(mockTool{name: "dup"}); err != nil {
|
|
t.Fatalf("first Register: %v", err)
|
|
}
|
|
err := d.Register(mockTool{name: "dup"})
|
|
if !errors.Is(err, registry.ErrDuplicate) {
|
|
t.Fatalf("second Register error = %v, want registry.ErrDuplicate", err)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_UnknownTool(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
_, err := d.Invoke(context.Background(), "nope", nil)
|
|
if !errors.Is(err, ErrUnknownTool) {
|
|
t.Fatalf("Invoke error = %v, want ErrUnknownTool", err)
|
|
}
|
|
var te *ToolError
|
|
if !errors.As(err, &te) {
|
|
t.Fatalf("error is %T, want *ToolError", err)
|
|
}
|
|
if te.Name != "nope" {
|
|
t.Fatalf("ToolError.Name = %q, want nope", te.Name)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_EmptyName(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
if _, err := d.Invoke(context.Background(), "", nil); !errors.Is(err, ErrUnknownTool) {
|
|
t.Fatalf("Invoke(\"\") error = %v, want ErrUnknownTool", err)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_NotATool(t *testing.T) {
|
|
t.Parallel()
|
|
d, reg := newDispatcher(t)
|
|
if err := reg.Register(registry.Capability{Name: "notatool", Kind: registry.KindSkill, Value: "skill"}); err != nil {
|
|
t.Fatalf("Register skill cap: %v", err)
|
|
}
|
|
if _, err := d.Invoke(context.Background(), "notatool", nil); !errors.Is(err, ErrUnknownTool) {
|
|
t.Fatalf("Invoke(non-tool) error = %v, want ErrUnknownTool", err)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_MissingRequired(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
params := map[string]any{"required": []any{"must"}}
|
|
if err := d.Register(mockTool{name: "req", params: params}); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
_, err := d.Invoke(context.Background(), "req", map[string]any{})
|
|
if !errors.Is(err, ErrInvalidArgs) {
|
|
t.Fatalf("Invoke error = %v, want ErrInvalidArgs", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "missing properties") {
|
|
t.Fatalf("error message = %q, want missing-required hint", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestInvoke_TypeMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
params := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{"count": map[string]any{"type": "integer"}},
|
|
"required": []any{"count"},
|
|
}
|
|
if err := d.Register(mockTool{name: "typed", params: params}); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
_, err := d.Invoke(context.Background(), "typed", map[string]any{"count": "not-a-number"})
|
|
if !errors.Is(err, ErrInvalidArgs) {
|
|
t.Fatalf("Invoke error = %v, want ErrInvalidArgs", err)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_IntegerAcceptsIntegralFloat(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
params := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{"count": map[string]any{"type": "integer"}},
|
|
"required": []any{"count"},
|
|
}
|
|
if err := d.Register(mockTool{name: "count", params: params}); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
// JSON decoding yields float64; an integral float must satisfy "integer".
|
|
if _, err := d.Invoke(context.Background(), "count", map[string]any{"count": float64(3)}); err != nil {
|
|
t.Fatalf("Invoke(integral float) error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_UnknownArgIgnored(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
params := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{"a": map[string]any{"type": "string"}},
|
|
"required": []any{"a"},
|
|
}
|
|
if err := d.Register(mockTool{name: "loose", params: params, execFn: func(_ context.Context, args any) (any, error) {
|
|
return args, nil
|
|
}}); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
// An unknown field is tolerated; the valid required arg still executes.
|
|
if _, err := d.Invoke(context.Background(), "loose", map[string]any{"a": "x", "extra": 42}); err != nil {
|
|
t.Fatalf("Invoke error = %v, want nil (unknown arg ignored)", err)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_Timeout(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t, WithDefaultTimeout(20*time.Millisecond))
|
|
if err := d.Register(mockTool{name: "slow", execFn: func(_ context.Context, _ any) (any, error) {
|
|
time.Sleep(200 * time.Millisecond) // ignores ctx on purpose
|
|
return "done", nil
|
|
}}); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
start := time.Now()
|
|
_, err := d.Invoke(context.Background(), "slow", nil)
|
|
if !errors.Is(err, ErrTimeout) {
|
|
t.Fatalf("Invoke error = %v, want ErrTimeout", err)
|
|
}
|
|
if d := time.Since(start); d > 150*time.Millisecond {
|
|
t.Fatalf("Invoke took %v, want it interrupted near the 20ms deadline", d)
|
|
}
|
|
var te *ToolError
|
|
if !errors.As(err, &te) {
|
|
t.Fatalf("error is %T, want *ToolError", err)
|
|
}
|
|
}
|
|
|
|
func TestSetToolTimeout_DisableOverride(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t, WithDefaultTimeout(10*time.Millisecond))
|
|
if err := d.Register(mockTool{name: "sleepy", execFn: func(_ context.Context, _ any) (any, error) {
|
|
time.Sleep(60 * time.Millisecond)
|
|
return "ok", nil
|
|
}}); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
|
|
// Without an override, the tool times out.
|
|
if _, err := d.Invoke(context.Background(), "sleepy", nil); !errors.Is(err, ErrTimeout) {
|
|
t.Fatalf("Invoke (default) error = %v, want ErrTimeout", err)
|
|
}
|
|
|
|
// Disabling the deadline (0) lets the tool run to completion.
|
|
if err := d.SetToolTimeout("sleepy", 0); err != nil {
|
|
t.Fatalf("SetToolTimeout: %v", err)
|
|
}
|
|
got, err := d.Invoke(context.Background(), "sleepy", nil)
|
|
if err != nil {
|
|
t.Fatalf("Invoke (override 0) error = %v, want nil", err)
|
|
}
|
|
if got != "ok" {
|
|
t.Fatalf("Invoke result = %v, want ok", got)
|
|
}
|
|
}
|
|
|
|
func TestSetToolTimeout_EmptyName(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
if err := d.SetToolTimeout("", time.Second); !errors.Is(err, ErrUnknownTool) {
|
|
t.Fatalf("SetToolTimeout(\"\") error = %v, want ErrUnknownTool", err)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_PanicRecovery(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
if err := d.Register(mockTool{name: "boom", execFn: func(context.Context, any) (any, error) {
|
|
panic("kaboom")
|
|
}}); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
_, err := d.Invoke(context.Background(), "boom", nil)
|
|
var te *ToolError
|
|
if !errors.As(err, &te) {
|
|
t.Fatalf("error is %T, want *ToolError", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "panicked") {
|
|
t.Fatalf("error message = %q, want panic hint", err.Error())
|
|
}
|
|
if errors.Is(err, ErrTimeout) {
|
|
t.Fatalf("panic error wrongly classified as ErrTimeout: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_ExecuteError(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
boom := errors.New("boom")
|
|
if err := d.Register(mockTool{name: "fail", execFn: func(context.Context, any) (any, error) {
|
|
return nil, boom
|
|
}}); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
_, err := d.Invoke(context.Background(), "fail", nil)
|
|
var te *ToolError
|
|
if !errors.As(err, &te) {
|
|
t.Fatalf("error is %T, want *ToolError", err)
|
|
}
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("Invoke error = %v, want to wrap the tool's %v", err, boom)
|
|
}
|
|
}
|
|
|
|
func TestInvoke_ParametersError(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t)
|
|
schemaErr := errors.New("bad schema")
|
|
if err := d.Register(mockTool{name: "badschema", paramsErr: schemaErr}); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
_, err := d.Invoke(context.Background(), "badschema", nil)
|
|
var te *ToolError
|
|
if !errors.As(err, &te) {
|
|
t.Fatalf("error is %T, want *ToolError", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid tool schema") {
|
|
t.Fatalf("error message = %q, want schema hint", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestInvoke_ContextAwareTool(t *testing.T) {
|
|
t.Parallel()
|
|
d, _ := newDispatcher(t, WithDefaultTimeout(20*time.Millisecond))
|
|
if err := d.Register(mockTool{name: "aware", execFn: func(ctx context.Context, _ any) (any, error) {
|
|
<-ctx.Done() // respects the deadline and returns the context error
|
|
return nil, ctx.Err()
|
|
}}); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
if _, err := d.Invoke(context.Background(), "aware", nil); !errors.Is(err, ErrTimeout) {
|
|
t.Fatalf("Invoke error = %v, want ErrTimeout", err)
|
|
}
|
|
}
|
|
|
|
func TestToolError_ErrorString(t *testing.T) {
|
|
t.Parallel()
|
|
e := &ToolError{Name: "x", Message: "bad", cause: ErrInvalidArgs}
|
|
if got := e.Error(); !strings.Contains(got, `tool "x" failed`) || !strings.Contains(got, "bad") {
|
|
t.Fatalf("Error() = %q, want name+message", got)
|
|
}
|
|
if !errors.Is(e, ErrInvalidArgs) {
|
|
t.Fatalf("errors.Is(ToolError, ErrInvalidArgs) = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestValidateArgs_Direct(t *testing.T) {
|
|
t.Parallel()
|
|
props := func(t string) map[string]any {
|
|
return map[string]any{"type": "object", "properties": map[string]any{"v": map[string]any{"type": t}}}
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
schema map[string]any
|
|
args map[string]any
|
|
wantErr bool
|
|
}{
|
|
{name: "nil schema", schema: nil, args: nil, wantErr: false},
|
|
{name: "required missing", schema: map[string]any{"required": []any{"r"}}, args: map[string]any{}, wantErr: true},
|
|
{name: "required present", schema: map[string]any{"required": []any{"r"}}, args: map[string]any{"r": 1}, wantErr: false},
|
|
{name: "type string ok", schema: props("string"), args: map[string]any{"v": "x"}, wantErr: false},
|
|
{name: "type string mismatch", schema: props("string"), args: map[string]any{"v": []any{"x"}}, wantErr: true},
|
|
{name: "type array ok", schema: props("array"), args: map[string]any{"v": []any{1}}, wantErr: false},
|
|
{name: "type object ok", schema: props("object"), args: map[string]any{"v": map[string]any{}}, wantErr: false},
|
|
{name: "type boolean ok", schema: props("boolean"), args: map[string]any{"v": true}, wantErr: false},
|
|
{name: "type number ok", schema: props("number"), args: map[string]any{"v": 2.5}, wantErr: false},
|
|
{name: "integer as number ok", schema: props("number"), args: map[string]any{"v": 2}, wantErr: false},
|
|
{name: "unknown field ignored", schema: props("string"), args: map[string]any{"v": "x", "u": 9}, wantErr: false},
|
|
{name: "coerce string to number", schema: props("number"), args: map[string]any{"v": "3"}, wantErr: false},
|
|
{name: "coerce string to integer", schema: props("integer"), args: map[string]any{"v": "3"}, wantErr: false},
|
|
{name: "coerce string to boolean", schema: props("boolean"), args: map[string]any{"v": "true"}, wantErr: false},
|
|
{name: "coerce string to array", schema: props("array"), args: map[string]any{"v": "[1,2]"}, wantErr: false},
|
|
{name: "coerce string to object", schema: props("object"), args: map[string]any{"v": `{"a":1}`}, wantErr: false},
|
|
{name: "bad coercion still error", schema: props("number"), args: map[string]any{"v": "abc"}, wantErr: true},
|
|
{name: "default fills missing", schema: map[string]any{"type": "object", "properties": map[string]any{"v": map[string]any{"type": "string", "default": "x"}}}, args: map[string]any{}, wantErr: false},
|
|
}
|
|
for _, tt := range cases {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
err := validateArgs(tt.schema, tt.args)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Fatalf("validateArgs(%v, %v) err = %v, wantErr=%v", tt.schema, tt.args, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateArgs_CoercionValues(t *testing.T) {
|
|
t.Parallel()
|
|
schema := func(typ string) map[string]any {
|
|
return map[string]any{"type": "object", "properties": map[string]any{"v": map[string]any{"type": typ}}}
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
typ string
|
|
input any
|
|
want any
|
|
}{
|
|
{name: "string to number", typ: "number", input: "3", want: float64(3)},
|
|
{name: "string to integer", typ: "integer", input: "4", want: float64(4)},
|
|
{name: "string to boolean true", typ: "boolean", input: "true", want: true},
|
|
{name: "number to string", typ: "string", input: 7, want: "7"},
|
|
}
|
|
for _, tt := range cases {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
args := map[string]any{"v": tt.input}
|
|
if err := validateArgs(schema(tt.typ), args); err != nil {
|
|
t.Fatalf("validateArgs error = %v, want nil", err)
|
|
}
|
|
if !reflect.DeepEqual(args["v"], tt.want) {
|
|
t.Fatalf("coerced v = %#v, want %#v", args["v"], tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateArgs_Defaults(t *testing.T) {
|
|
t.Parallel()
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"q": map[string]any{"type": "string", "default": "hello"},
|
|
},
|
|
}
|
|
args := map[string]any{}
|
|
if err := validateArgs(schema, args); err != nil {
|
|
t.Fatalf("validateArgs error = %v, want nil", err)
|
|
}
|
|
if got := args["q"]; got != "hello" {
|
|
t.Fatalf("args[q] = %#v, want default hello", got)
|
|
}
|
|
}
|
|
|
|
func TestValidateArgs_ErrorPath(t *testing.T) {
|
|
t.Parallel()
|
|
nested := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"user": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"age": map[string]any{"type": "integer"},
|
|
},
|
|
},
|
|
},
|
|
"required": []any{"user"},
|
|
}
|
|
t.Run("nested property path", func(t *testing.T) {
|
|
t.Parallel()
|
|
err := validateArgs(nested, map[string]any{"user": map[string]any{"age": "x"}})
|
|
if err == nil {
|
|
t.Fatalf("validateArgs error = nil, want failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "/user/age") {
|
|
t.Fatalf("error = %q, want instance path /user/age", err.Error())
|
|
}
|
|
})
|
|
t.Run("required property path", func(t *testing.T) {
|
|
t.Parallel()
|
|
err := validateArgs(nested, map[string]any{})
|
|
if err == nil {
|
|
t.Fatalf("validateArgs error = nil, want failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "/user") {
|
|
t.Fatalf("error = %q, want instance path /user", err.Error())
|
|
}
|
|
})
|
|
}
|