package server import ( "context" "encoding/json" "testing" actool "github.com/Autumn-27/norma/tool" "github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/db" ) func names(tools []actool.CoreTool) map[string]actool.CoreTool { m := map[string]actool.CoreTool{} for _, t := range tools { m[t.Name()] = t } return m } // TestWireTools verifies end-to-end against the live dev PG: wireTools seeds the // catalog, ToolResolve keeps record_fact for worker but drops it for planner (not // bound), and an edited description + injected default flow through. func TestWireTools(t *testing.T) { dsn, _, err := db.DSN() if err != nil { t.Skipf("no database config (%v) — skipping", err) } pg, err := db.Open(dsn) if err != nil { t.Skipf("postgres unavailable (%v) — skipping", err) } defer pg.Close() wireTools(pg, nil) // nil domainReg: test only covers filter/decoration, not injection t.Cleanup(func() { agent.ToolResolve = nil }) // Prevent interactive-shell Bash decoration: if the worker agent has // interactive_shell=true in the DB, ToolResolve would append a note to // Bash's description. Disable that for this test so the undecorated // pass-through assertion holds regardless of DB state. t.Setenv("AGENT_CORE_DISABLE_INTERACTIVE_SHELL", "1") // Seeding populated the catalog. rf, err := pg.GetTool("record_fact") if err != nil || rf == nil { t.Fatalf("record_fact not seeded: %v", err) } // Build a base as the worker does: its domain tools + defaults, resolved for // "worker" then for "planner". ts := agent.NewToolSet(nil, "") base := append(ts.WorkerTools(), actool.DefaultTools()...) ctx := context.Background() worker := names(agent.ToolResolve(ctx, "worker", base)) if _, ok := worker["record_fact"]; !ok { t.Error("worker lost record_fact") } // SDK generic tools aren't seeded → ToolResolve passes them through unchanged // (same object, original description — not decorated). No startup prune touches // non-catalog rows, so future user-defined custom tools survive too. if bash, ok := worker["Bash"]; !ok { t.Error("worker lost Bash (should pass through)") } else if bash.Description() != actool.NewBash().Description() { t.Error("Bash should pass through undecorated, but description changed") } // planner is not bound to record_fact → resolving a base that contains it drops it. planner := names(agent.ToolResolve(ctx, "planner", base)) if _, ok := planner["record_fact"]; ok { t.Error("planner should not get record_fact (not bound)") } // Edit description + add a default to a scalar param, then confirm the resolved // worker tool reflects both. Restore from code default on cleanup. t.Cleanup(func() { _ = pg.UpsertToolForce(rf.Key, rf.Description, rf.Schema, mustJSON(rf.Agents)) }) var schema map[string]any _ = json.Unmarshal(rf.Schema, &schema) if props, ok := schema["properties"].(map[string]any); ok { if conf, ok := props["confidence"].(map[string]any); ok { conf["default"] = "inferred" } } edited := mustJSON(schema) if err := pg.UpdateTool(rf.Key, "EDITED DESC", edited, mustJSON([]string{"worker"}), true); err != nil { t.Fatal(err) } worker2 := names(agent.ToolResolve(ctx, "worker", base)) got := worker2["record_fact"] if got == nil { t.Fatal("record_fact missing after edit") } if got.Description() != "EDITED DESC" { t.Errorf("description = %q, want EDITED DESC", got.Description()) } // Default injection: omit confidence → handler input should carry the default. // We can't run the real handler (nil store), but InputSchema must show the default. sc := got.InputSchema() props := sc["properties"].(map[string]any) conf := props["confidence"].(map[string]any) if conf["default"] != "inferred" { t.Errorf("confidence.default = %v, want inferred", conf["default"]) } } // TestHealListTaskCredentialsSchema: a list_task_credentials row seeded by an older // build carries "required": [""]. The startup heal drops that key, leaves the rest of // the row alone, changes nothing on a second run, and touches no other tool. func TestHealListTaskCredentialsSchema(t *testing.T) { dsn, _, err := db.DSN() if err != nil { t.Skipf("no database config (%v) — skipping", err) } pg, err := db.Open(dsn) if err != nil { t.Skipf("postgres unavailable (%v) — skipping", err) } t.Cleanup(func() { pg.Close() }) s := &Server{m: &Manager{pg: pg}} const key = "list_task_credentials" // Other tools the heal must never touch: a normal required list, and the same // malformed value under a different key. const probeRequired, probeBlank = "heal_probe_required", "heal_probe_blank" const broken = `{"type":"object","properties":{},"required":[""]}` // The package test DB is shared: put back whatever it held for the real key. old, err := pg.GetTool(key) if err != nil { t.Fatal(err) } t.Cleanup(func() { if old == nil { pg.Exec(`DELETE FROM tools WHERE key=$1`, key) } else { pg.Exec(`UPDATE tools SET schema=$2::jsonb WHERE key=$1`, key, string(old.Schema)) } pg.Exec(`DELETE FROM tools WHERE key IN ($1,$2)`, probeRequired, probeBlank) }) for k, schema := range map[string]string{ key: broken, probeRequired: `{"type":"object","properties":{"x":{"type":"string"}},"required":["x"]}`, probeBlank: broken, } { if _, err := pg.Exec(`INSERT INTO tools(key, description, schema, agents) VALUES ($1, 'legacy row', $2::jsonb, '["worker"]') ON CONFLICT (key) DO UPDATE SET schema=EXCLUDED.schema`, k, schema); err != nil { t.Fatal(err) } } type row struct { schema, description, agents, updatedAt string enabled bool } read := func(k string) row { t.Helper() var r row if err := pg.QueryRow(`SELECT schema::text, description, agents::text, updated_at::text, enabled FROM tools WHERE key=$1`, k). Scan(&r.schema, &r.description, &r.agents, &r.updatedAt, &r.enabled); err != nil { t.Fatalf("read %s: %v", k, err) } return r } before, beforeRequired, beforeBlank := read(key), read(probeRequired), read(probeBlank) s.healListTaskCredentialsSchema() healed := read(key) var schema map[string]any if err := json.Unmarshal([]byte(healed.schema), &schema); err != nil { t.Fatal(err) } if _, ok := schema["required"]; ok { t.Fatalf("required survived the heal: %s", healed.schema) } if schema["type"] != "object" || schema["properties"] == nil { t.Errorf("heal dropped more than required: %s", healed.schema) } if healed.description != before.description || healed.agents != before.agents || healed.enabled != before.enabled { t.Errorf("heal changed description/bindings/enabled: %+v -> %+v", before, healed) } s.healListTaskCredentialsSchema() if again := read(key); again != healed { t.Errorf("second heal changed the row: %+v -> %+v", healed, again) } if got := read(probeRequired); got != beforeRequired { t.Errorf("heal touched a tool with a non-empty required: %+v -> %+v", beforeRequired, got) } if got := read(probeBlank); got != beforeBlank { t.Errorf("heal touched another tool key: %+v -> %+v", beforeBlank, got) } } func mustJSON(v any) json.RawMessage { b, _ := json.Marshal(v) return b }