Files
ARTEX/server/tools_wire_test.go
T
BaaRai 37f2573a53 agent: guard ToolAugment/ToolResolve hooks against concurrent access
server wiring (wireAgentAugment, wireTools) assigned the package-level
hooks agent.ToolAugment and agent.ToolResolve while restored tasks could
already be running AugmentTools on other goroutines. go test -race ./server
reported this as a DATA RACE between agent.AugmentTools and
server.wireAgentAugment.

Each hook is now an unexported variable behind a sync.RWMutex, with a
setter (SetToolAugment, SetToolResolve) and a getter (ToolAugment(),
ToolResolve()). AugmentTools copies the function value through the getter
and calls it outside the lock. A nil hook still means no augmentation /
no resolution, as before.

FindingTrafficBindingEnabled is wired in the same server/assembly.go code
and read on the same AugmentTools path, so it gets the same treatment.

Adds TestAugmentToolsHooksConcurrentSwap, which runs AugmentTools while
the setters swap the hooks, so -race catches an unsynchronised access
without depending on test ordering.
2026-11-14 13:46:03 +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.SetToolResolve(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
}