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 required argument") { 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": 1}, 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}, } 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) } }) } }