bcd246bf85
Anade pkg/tools/devicemesh con Client HTTP al device_agent + ToolRegistry con 16 tools standard (exec, fs.*, git.*, docker.*, proc.*, pkg.*, shell.eval). RegisterBuiltins filtra por mode user/sudo via RequiresApproval flag. Hook al pkg/decision con ActionKindDeviceMesh + DeviceMeshAction. Runner soporta dispatch via NewRunnerWithDeviceMesh (back-compat NewRunner). Tests: 25 nuevos en devicemesh + 4 en runner. Build clean.
220 lines
6.1 KiB
Go
220 lines
6.1 KiB
Go
package devicemesh
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestToolsForLLM_EmptyRegistry(t *testing.T) {
|
|
if got := ToolsForLLM(nil); len(got) != 0 {
|
|
t.Errorf("nil reg → expected 0 tools, got %d", len(got))
|
|
}
|
|
reg := NewToolRegistry(nil)
|
|
if got := ToolsForLLM(reg); len(got) != 0 {
|
|
t.Errorf("empty reg → expected 0 tools, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestToolsForLLM_PreservesNamesAndDescription(t *testing.T) {
|
|
reg := NewToolRegistry(NewClient("http://nowhere.invalid"))
|
|
reg.Register(ToolSpec{
|
|
Name: "exec",
|
|
Capability: "shell.exec",
|
|
Description: "Run a command",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"required": []string{"argv"},
|
|
"properties": map[string]any{
|
|
"argv": map[string]any{"type": "array", "description": "argument vector"},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(ToolSpec{
|
|
Name: "pkg.install",
|
|
Capability: "pkg.install",
|
|
Description: "Install a package",
|
|
RequiresApproval: true,
|
|
})
|
|
|
|
got := ToolsForLLM(reg)
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 tools, got %d", len(got))
|
|
}
|
|
|
|
// Alpha-sorted by name
|
|
if got[0].Def.Name != "exec" || got[1].Def.Name != "pkg.install" {
|
|
t.Errorf("name order: %v", []string{got[0].Def.Name, got[1].Def.Name})
|
|
}
|
|
|
|
if !strings.Contains(got[0].Def.Description, "device_mesh: shell.exec") {
|
|
t.Errorf("description missing device_mesh marker: %q", got[0].Def.Description)
|
|
}
|
|
if !strings.Contains(got[1].Def.Description, "(approval required)") {
|
|
t.Errorf("approval-required marker missing: %q", got[1].Def.Description)
|
|
}
|
|
|
|
// Param extraction
|
|
if len(got[0].Def.Parameters) != 1 || got[0].Def.Parameters[0].Name != "argv" {
|
|
t.Errorf("expected one param 'argv', got %+v", got[0].Def.Parameters)
|
|
}
|
|
if !got[0].Def.Parameters[0].Required {
|
|
t.Errorf("expected argv to be required")
|
|
}
|
|
}
|
|
|
|
func TestAdaptTool_ExecRoutesThroughRegistry(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var req CapabilityRequest
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(body, &req)
|
|
// Echo the args back so we can assert ArgMapping ran.
|
|
_ = json.NewEncoder(w).Encode(CapabilityResponse{
|
|
RequestID: req.RequestID,
|
|
OK: true,
|
|
Result: map[string]any{"got": req.Args},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
reg := NewToolRegistry(NewClient(srv.URL))
|
|
spec := ToolSpec{
|
|
Name: "echo",
|
|
Capability: "x.echo",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"required": []string{"msg"},
|
|
"properties": map[string]any{
|
|
"msg": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
ArgMapping: func(in map[string]any) (map[string]any, error) {
|
|
return map[string]any{"msg_upper": strings.ToUpper(in["msg"].(string))}, nil
|
|
},
|
|
}
|
|
reg.Register(spec)
|
|
tool := AdaptTool(reg, spec)
|
|
|
|
res := tool.Exec(context.Background(), map[string]any{"msg": "hi"})
|
|
if res.Err != nil {
|
|
t.Fatalf("exec err: %v", res.Err)
|
|
}
|
|
if !strings.Contains(res.Output, "HI") {
|
|
t.Errorf("expected HI in output, got %q", res.Output)
|
|
}
|
|
}
|
|
|
|
func TestAdaptTool_PropagatesValidationError(t *testing.T) {
|
|
reg := NewToolRegistry(NewClient("http://nowhere.invalid"))
|
|
spec := ToolSpec{
|
|
Name: "needs_int",
|
|
Capability: "x.y",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"required": []string{"n"},
|
|
"properties": map[string]any{
|
|
"n": map[string]any{"type": "integer"},
|
|
},
|
|
"additionalProperties": false,
|
|
},
|
|
}
|
|
reg.Register(spec)
|
|
tool := AdaptTool(reg, spec)
|
|
|
|
res := tool.Exec(context.Background(), map[string]any{"n": "not-an-int"})
|
|
if res.Err == nil {
|
|
t.Fatalf("expected validation error")
|
|
}
|
|
if !strings.Contains(res.Err.Error(), "needs_int") {
|
|
t.Errorf("error should mention tool name: %v", res.Err)
|
|
}
|
|
}
|
|
|
|
func TestFormatToolResult(t *testing.T) {
|
|
if got := formatToolResult(nil); got != "" {
|
|
t.Errorf("nil → expected empty, got %q", got)
|
|
}
|
|
if got := formatToolResult("plain"); got != "plain" {
|
|
t.Errorf("string passthrough: %q", got)
|
|
}
|
|
if got := formatToolResult(map[string]any{"a": 1}); got != `{"a":1}` {
|
|
t.Errorf("map encode: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestFilterByAllowed(t *testing.T) {
|
|
reg := NewToolRegistry(NewClient("http://x"))
|
|
reg.Register(ToolSpec{Name: "a", Capability: "x.a"})
|
|
reg.Register(ToolSpec{Name: "b", Capability: "x.b"})
|
|
reg.Register(ToolSpec{Name: "c", Capability: "x.c"})
|
|
|
|
// Empty allow-list = passthrough
|
|
if got := FilterByAllowed(reg, nil); got.Len() != 3 {
|
|
t.Errorf("nil allowed → expected 3, got %d", got.Len())
|
|
}
|
|
|
|
// Subset
|
|
filtered := FilterByAllowed(reg, []string{"a", "c", "zzz"}) // zzz is silently dropped
|
|
if filtered.Len() != 2 {
|
|
t.Fatalf("expected 2 filtered, got %d", filtered.Len())
|
|
}
|
|
names := filtered.Names()
|
|
if names[0] != "a" || names[1] != "c" {
|
|
t.Errorf("unexpected names after filter: %v", names)
|
|
}
|
|
|
|
// Same Client shared
|
|
if filtered.Client() != reg.Client() {
|
|
t.Errorf("filtered should share Client with source")
|
|
}
|
|
|
|
// Nil source
|
|
if FilterByAllowed(nil, []string{"a"}) != nil {
|
|
t.Errorf("nil source → expected nil")
|
|
}
|
|
}
|
|
|
|
func TestParamsFromSchema_EdgeCases(t *testing.T) {
|
|
if got := paramsFromSchema(nil); got != nil {
|
|
t.Errorf("nil schema → expected nil, got %v", got)
|
|
}
|
|
// Missing properties
|
|
if got := paramsFromSchema(map[string]any{"type": "object"}); got != nil {
|
|
t.Errorf("no properties → expected nil, got %v", got)
|
|
}
|
|
// "required" as []any (json.Unmarshal default)
|
|
got := paramsFromSchema(map[string]any{
|
|
"required": []any{"foo"},
|
|
"properties": map[string]any{
|
|
"foo": map[string]any{"type": "string"},
|
|
"bar": map[string]any{"type": "integer"},
|
|
},
|
|
})
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 params, got %d", len(got))
|
|
}
|
|
// Sorted alpha: bar, foo
|
|
if got[0].Name != "bar" || got[1].Name != "foo" {
|
|
t.Errorf("expected sorted [bar, foo], got %+v", got)
|
|
}
|
|
if got[0].Required {
|
|
t.Errorf("bar should not be required")
|
|
}
|
|
if !got[1].Required {
|
|
t.Errorf("foo should be required")
|
|
}
|
|
// Type defaulting
|
|
got2 := paramsFromSchema(map[string]any{
|
|
"properties": map[string]any{
|
|
"x": map[string]any{},
|
|
},
|
|
})
|
|
if len(got2) != 1 || got2[0].Type != "string" {
|
|
t.Errorf("expected type default 'string', got %+v", got2)
|
|
}
|
|
}
|