//go:build majordomo package agent import ( "context" "encoding/json" "testing" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" "gitea.stevedudenhoeffer.com/steve/pansy/internal/config" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" "gitea.stevedudenhoeffer.com/steve/pansy/internal/store" ) // TestToolboxScenario is the #19 demo harness: it drives the DESIGN scenario // through the tool layer (JSON args → DefineTool → service) rather than a live // model — fill the NE corner with garlic, the NW with basil, the south with // beans — and checks describe_garden reports three groups in the right places. // It also confirms the toolbox inherits pansy's ACL (a viewer is refused). // // Build/run with: go test -tags majordomo ./internal/agent/ func TestToolboxScenario(t *testing.T) { ctx := context.Background() db, err := store.Open(":memory:") if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { db.Close() }) if err := db.Migrate(ctx); err != nil { t.Fatalf("migrate: %v", err) } svc := service.New(db, &config.Config{Registration: config.RegistrationOpen, LocalAuth: true}) owner, err := svc.Register(ctx, service.RegisterInput{Email: "a@example.com", DisplayName: "A", Password: "password123"}) if err != nil { t.Fatalf("register: %v", err) } box := NewToolbox(svc, owner.ID) call := func(name string, args any) llm.ToolResult { t.Helper() raw, _ := json.Marshal(args) return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: raw}) } // Garden + plants are set up directly (there are no create_garden/plant tools); // the agent-facing bits — object + fills + describe — go through the toolbox. g, err := svc.CreateGarden(ctx, owner.ID, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) if err != nil { t.Fatalf("garden: %v", err) } garlic := mustPlant(t, svc, owner.ID, "Garlic", 15, "🧄") basil := mustPlant(t, svc, owner.ID, "Basil", 25, "🌿") beans := mustPlant(t, svc, owner.ID, "Beans", 10, "🫘") // create_object → a 400×400 bed. res := call("create_object", map[string]any{ "gardenId": g.ID, "kind": "bed", "name": "Bed 1", "xCm": 1000, "yCm": 1000, "widthCm": 400, "heightCm": 400, }) if res.IsError { t.Fatalf("create_object: %s", res.Content) } var bed struct { ID int64 `json:"id"` } if err := json.Unmarshal([]byte(res.Content), &bed); err != nil { t.Fatalf("decode created object: %v", err) } // fill_region for each corner/half. for _, f := range []struct { region string plantID int64 }{{"ne", garlic.ID}, {"nw", basil.ID}, {"south", beans.ID}} { if r := call("fill_region", map[string]any{"objectId": bed.ID, "region": f.region, "plantId": f.plantID}); r.IsError { t.Fatalf("fill_region %s: %s", f.region, r.Content) } } // describe_garden → parse and check locations. res = call("describe_garden", map[string]any{"gardenId": g.ID}) if res.IsError { t.Fatalf("describe_garden: %s", res.Content) } var desc service.DescribeResult if err := json.Unmarshal([]byte(res.Content), &desc); err != nil { t.Fatalf("decode describe: %v", err) } if len(desc.Objects) != 1 { t.Fatalf("objects = %d, want 1", len(desc.Objects)) } seen := map[string]map[string]bool{} for _, p := range desc.Objects[0].Plantings { if seen[p.Plant] == nil { seen[p.Plant] = map[string]bool{} } seen[p.Plant][p.Location] = true } if !seen["Garlic"]["NE corner"] { t.Errorf("garlic at %v, want NE corner", seen["Garlic"]) } if !seen["Basil"]["NW corner"] { t.Errorf("basil at %v, want NW corner", seen["Basil"]) } if len(seen["Beans"]) == 0 { t.Error("beans produced no plops") } for loc := range seen["Beans"] { if loc == "north" || loc == "NE corner" || loc == "NW corner" || loc == "center" { t.Errorf("beans at %q, want only southern locations", loc) } } // ACL: a viewer's fill_region is refused (the toolbox runs as that actor). viewerUser, err := svc.Register(ctx, service.RegisterInput{Email: "v@example.com", DisplayName: "V", Password: "password123"}) if err != nil { t.Fatalf("register viewer: %v", err) } if _, err := svc.AddShare(ctx, owner.ID, g.ID, "v@example.com", domain.RoleViewer); err != nil { t.Fatalf("share: %v", err) } viewerBox := NewToolbox(svc, viewerUser.ID) vr := viewerBox.Execute(ctx, llm.ToolCall{ID: "2", Name: "fill_region", Arguments: mustJSON(t, map[string]any{ "objectId": bed.ID, "region": "all", "plantId": garlic.ID, })}) if !vr.IsError { t.Errorf("viewer fill_region succeeded, want a permission error") } } func mustJSON(t *testing.T, v any) json.RawMessage { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatalf("marshal: %v", err) } return b } func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spacing float64, icon string) *domain.Plant { t.Helper() p, err := svc.CreatePlant(context.Background(), owner, service.PlantInput{ Name: name, Category: domain.CategoryVegetable, SpacingCM: spacing, Color: "#4a7c3f", Icon: icon, }) if err != nil { t.Fatalf("create plant %s: %v", name, err) } return p }