mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
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.
200 lines
7.0 KiB
Go
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
|
|
}
|