Files
ARTEX/server/tools_wire_test.go
T
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

200 lines
7.0 KiB
Go

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
}