Files
ARTEX/agent/toolcatalog_test.go
BaaRai a44af3ca2b fix(tools): harden obj against blank required names and heal seeded list_task_credentials rows
The previous commit stops list_task_credentials from building its schema
with obj(map[string]any{}, ""). Two gaps remained:

- obj still emitted whatever it was given, so the same typo on another tool
  would again produce "required": [""]. obj now drops names that are blank
  after strings.TrimSpace and omits the "required" key when nothing is left.

- Tool schemas are cached in the tools table and SeedTool is insert-only, so
  the code fix only reached fresh installs. Existing databases kept the
  malformed row and strict providers kept rejecting every worker run.
  healListTaskCredentialsSchema runs at startup next to the other tool
  seeds and removes the "required" key from that one row, only when it is
  exactly [""]:

    UPDATE tools SET schema = schema - 'required', updated_at=now()
    WHERE key='list_task_credentials' AND schema->'required' = '[""]'::jsonb

  It is idempotent (a healed row no longer matches), needs no settings flag,
  and leaves the description, bindings, enabled flag and every other tool
  untouched. tool_schema_refresh_v7 is not bumped.

Tests: TestObjDropsBlankRequired and
TestListTaskCredentialsSchemaHasNoBlankRequired (agent),
TestHealListTaskCredentialsSchema (server, Postgres).
2026-11-10 09:46:49 +01:00

126 lines
4.0 KiB
Go

package agent
import (
"context"
"encoding/json"
"strings"
"testing"
actool "github.com/Autumn-27/norma/tool"
)
// TestBuiltinToolSeeds ensures the catalog builds from a nil-store ToolSet without
// panicking, has stable keys, unions agent bindings, and carries real descriptions.
func TestBuiltinToolSeeds(t *testing.T) {
seeds := BuiltinToolSeeds()
if len(seeds) == 0 {
t.Fatal("no seeds")
}
byKey := map[string]ToolSeed{}
for _, s := range seeds {
if s.Key == "" || s.Desc == "" {
t.Errorf("seed %q missing key/desc", s.Key)
}
byKey[s.Key] = s
}
// record_fact is bound to worker (also mainagent, which can log confirmed facts).
rf, ok := byKey["record_fact"]
hasWorker := false
for _, a := range rf.Agents {
if a == "worker" {
hasWorker = true
}
}
if !ok || !hasWorker {
t.Errorf("record_fact agents = %v, want to include worker", rf.Agents)
}
// add_task_scope is bound to the planner (deliberate scope widening).
if ts, ok := byKey["add_task_scope"]; !ok || len(ts.Agents) == 0 {
t.Errorf("add_task_scope not seeded / has no agent binding: %v", ts.Agents)
}
// SDK generic tools (incl. sleep, now part of DefaultTools) are deliberately NOT
// seeded — every agent owns them; they flow through ToolResolve untouched.
for _, k := range []string{"Bash", "Read", "Write", "Edit", "Grep", "sleep"} {
if _, ok := byKey[k]; ok {
t.Errorf("SDK tool %q should not be seeded", k)
}
}
}
// TestObjDropsBlankRequired: obj never emits a blank required name, and omits the
// "required" key entirely when no real name is left.
func TestObjDropsBlankRequired(t *testing.T) {
if got, ok := obj(map[string]any{}, "")["required"]; ok {
t.Errorf(`obj(props, "") required = %v, want no required key`, got)
}
got, _ := obj(map[string]any{}, " ", "x")["required"].([]string)
if len(got) != 1 || got[0] != "x" {
t.Errorf(`obj(props, " ", "x") required = %v, want [x]`, got)
}
}
// TestListTaskCredentialsSchemaHasNoBlankRequired guards the seeded schema of the
// parameterless list_task_credentials tool: "required": [""] is rejected by strict
// providers, which fails every worker run.
func TestListTaskCredentialsSchemaHasNoBlankRequired(t *testing.T) {
for _, s := range BuiltinToolSeeds() {
if s.Key != "list_task_credentials" {
continue
}
raw, err := json.Marshal(s.Schema)
if err != nil {
t.Fatal(err)
}
var schema struct {
Required []string `json:"required"`
}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("schema %s: %v", raw, err)
}
for _, r := range schema.Required {
if strings.TrimSpace(r) == "" {
t.Errorf("blank required entry in schema %s", raw)
}
}
return
}
t.Fatal("list_task_credentials not seeded")
}
// TestDecorateToolInjectsDefaults verifies a schema "default" fills a missing param
// before the underlying handler runs, and an explicitly-provided value is kept.
func TestDecorateToolInjectsDefaults(t *testing.T) {
var seen map[string]any
base := actool.Build(actool.Spec{
Name: "probe", Description: "orig",
Run: func(_ context.Context, in json.RawMessage, _ *actool.ToolContext) (actool.Result, error) {
_ = json.Unmarshal(in, &seen)
return actool.Text("ok"), nil
},
})
schema := map[string]any{"type": "object", "properties": map[string]any{
"limit": map[string]any{"type": "integer", "description": "n", "default": float64(3)},
"q": map[string]any{"type": "string", "description": "query"},
}}
dec := DecorateTool(base, "new desc", schema)
if dec.Description() != "new desc" {
t.Errorf("description = %q", dec.Description())
}
// limit omitted → default 3 injected; q kept.
if _, err := dec.Call(context.Background(), json.RawMessage(`{"q":"x"}`), nil); err != nil {
t.Fatal(err)
}
if seen["limit"] != float64(3) || seen["q"] != "x" {
t.Errorf("injected = %v, want limit=3 q=x", seen)
}
// limit provided → default does NOT override.
if _, err := dec.Call(context.Background(), json.RawMessage(`{"limit":9}`), nil); err != nil {
t.Fatal(err)
}
if seen["limit"] != float64(9) {
t.Errorf("limit = %v, want 9 (no override)", seen["limit"])
}
}