Add agent seam: bulk ops + majordomo tool wrappers (#19)
Build image / build-and-push (push) Successful in 5s
Gadfly review (reusable) / review (pull_request) Successful in 13m34s
Adversarial Review (Gadfly) / review (pull_request) Successful in 13m35s

Bulk, natural-language-shaped service operations (ACL-enforced like every other
op, so agent tools inherit permissions for free):
- ops.go: Region + NamedRegion (nw/ne/sw/se corners, north/south/east/west and
  top/bottom/left/right halves, "all"; -y is north in the local frame).
  FillRegion hex-packs plops at 2×radius pitch (radius = #15's max(1.5·spacing,
  15cm)) clipped to the region, skipping any candidate that would sit entirely
  inside an existing active plop. ClearObject soft-removes all active plops in one
  UPDATE. DescribeGarden returns a structured summary (dims, objects+version,
  plantings with plant/effective-count/rough compass location).
- store/plantings.go: ListActivePlantingsForObject + ClearObjectPlantings.

Agent toolbox (internal/agent), deliberately isolated:
- doc.go (untagged) keeps the package in the default build; tools.go is behind
  the `majordomo` build tag and NOT in go.mod, so `go build/test ./...` and the
  server binary carry no majordomo/LLM deps. Built + tested locally against real
  majordomo (go test -tags majordomo ./internal/agent/).
- NewToolbox(svc, actorID) exposes list_gardens, describe_garden, create_object,
  move_object, place_planting, fill_region, clear_object as llm.DefineTool
  wrappers — thin typed adapters over the service, each running as the bound
  actor.

Tests: NamedRegion for all names + unknown→ErrInvalidInput; deterministic
hex-packing count (60×60 bed → 4 plops) + re-fill skips covered; rotated bed
fills the correct LOCAL corner; ClearObject; viewer→ErrForbidden on fill/clear
but can describe; and the DESIGN corner/half scenario (garlic NE, basil NW, beans
south) verified via DescribeGarden. A tagged demo drives the same scenario
through the toolbox (JSON args → tool → service) and confirms a viewer is refused.

GOWORK=off go build/vet/test ./internal/... green (majordomo-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-19 00:54:03 -04:00
co-authored by Claude Opus 4.8
parent 245e0cbe71
commit d3b7dd06c9
6 changed files with 897 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
package service
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
func TestNamedRegion(t *testing.T) {
o := &domain.GardenObject{WidthCM: 200, HeightCM: 100} // hw=100, hh=50
cases := []struct {
name string
minX, minY, maxX, maxY float64
}{
{"all", -100, -50, 100, 50},
{"north", -100, -50, 100, 0},
{"top half", -100, -50, 100, 0},
{"south", -100, 0, 100, 50},
{"bottom", -100, 0, 100, 50},
{"east", 0, -50, 100, 50},
{"right half", 0, -50, 100, 50},
{"west", -100, -50, 0, 50},
{"nw", -100, -50, 0, 0},
{"NE corner", 0, -50, 100, 0},
{"northeast", 0, -50, 100, 0},
{"sw", -100, 0, 0, 50},
{"se corner", 0, 0, 100, 50},
}
for _, c := range cases {
r, err := NamedRegion(o, c.name)
if err != nil {
t.Errorf("%q: unexpected error %v", c.name, err)
continue
}
if r.MinX != c.minX || r.MinY != c.minY || r.MaxX != c.maxX || r.MaxY != c.maxY {
t.Errorf("%q = [%v,%v,%v,%v], want [%v,%v,%v,%v]", c.name, r.MinX, r.MinY, r.MaxX, r.MaxY, c.minX, c.minY, c.maxX, c.maxY)
}
}
if _, err := NamedRegion(o, "middle-ish"); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("unknown region err = %v, want ErrInvalidInput", err)
}
}
func TestDefaultPlopRadius(t *testing.T) {
if got := defaultPlopRadius(4); got != 15 { // 1.5*4=6 → floored to 15
t.Errorf("radius(4) = %v, want 15", got)
}
if got := defaultPlopRadius(20); got != 30 { // 1.5*20
t.Errorf("radius(20) = %v, want 30", got)
}
}
// seedFillBed makes a plantable bed of the given size centered in a big garden.
func seedFillBed(t *testing.T, s *Service, owner, gardenID int64, w, h float64) *domain.GardenObject {
t.Helper()
o, err := s.CreateObject(context.Background(), owner, gardenID, ObjectInput{
Kind: domain.KindBed, XCM: 1000, YCM: 1000, WidthCM: w, HeightCM: h,
})
if err != nil {
t.Fatalf("seed bed %vx%v: %v", w, h, err)
}
return o
}
func TestFillRegionDeterministicPacking(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed := seedFillBed(t, s, owner, g.ID, 60, 60) // hw=hh=30
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
region, _ := NamedRegion(bed, "all")
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
if err != nil {
t.Fatalf("FillRegion: %v", err)
}
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart → 4 plops (2 rows × 2).
if len(created) != 4 {
t.Fatalf("filled %d plops, want 4 (60×60 bed, radius 15)", len(created))
}
for _, p := range created {
if p.RadiusCM != 15 || p.PlantedAt == nil || p.DerivedCount < 1 {
t.Errorf("unexpected created plop: %+v", p)
}
if p.XCM < -30 || p.XCM > 30 || p.YCM < -30 || p.YCM > 30 {
t.Errorf("plop center out of bed bounds: %+v", p)
}
}
// Re-filling the same region skips everything (each candidate sits exactly on
// an existing plop → entirely inside it).
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
if err != nil {
t.Fatalf("second FillRegion: %v", err)
}
if len(again) != 0 {
t.Errorf("re-fill created %d plops, want 0 (all covered)", len(again))
}
}
func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
bed := seedFillBed(t, s, owner, g.ID, 400, 400)
// Rotate the bed 45°; the fill must still target the LOCAL NE corner.
rot := 45.0
bed, _ = s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{RotationDeg: &rot}, bed.Version)
plant := seedOwnPlant(t, s, owner, 20)
region, _ := NamedRegion(bed, "ne")
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
if err != nil {
t.Fatalf("FillRegion: %v", err)
}
if len(created) == 0 {
t.Fatal("expected some plops in the NE corner")
}
for _, p := range created {
// NE corner in local coords: x ≥ 0 (east), y ≤ 0 (north).
if p.XCM < 0 || p.YCM > 0 {
t.Errorf("plop not in local NE corner: %+v", p)
}
}
}
func TestClearObject(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); err != nil {
t.Fatalf("fill: %v", err)
}
n, err := s.ClearObject(ctx, owner, bed.ID)
if err != nil {
t.Fatalf("ClearObject: %v", err)
}
if n < 1 {
t.Fatalf("cleared %d, want ≥ 1", n)
}
full, _ := s.GardenFull(ctx, owner, g.ID)
if len(full.Plantings) != 0 {
t.Errorf("plantings after clear = %d, want 0", len(full.Plantings))
}
// Clearing an already-empty object clears 0.
if again, _ := s.ClearObject(ctx, owner, bed.ID); again != 0 {
t.Errorf("second clear = %d, want 0", again)
}
}
func TestOpsForbiddenForViewer(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
viewer := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10)
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
t.Fatalf("share: %v", err)
}
region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("viewer fill = %v, want ErrForbidden", err)
}
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("viewer clear = %v, want ErrForbidden", err)
}
// But a viewer can DescribeGarden (read).
if _, err := s.DescribeGarden(ctx, viewer, g.ID); err != nil {
t.Errorf("viewer describe = %v, want ok", err)
}
}
// TestFillScenario is the DESIGN scenario: garlic in the NE corner, basil in the
// NW, beans across the south — three distinct groups in the right places.
func TestFillScenario(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
bed := seedFillBed(t, s, owner, g.ID, 400, 400)
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
basil := seedNamedPlant(t, s, owner, "Basil", 25)
beans := seedNamedPlant(t, s, owner, "Beans", 10)
fill := func(name string, plantID int64) {
t.Helper()
region, err := NamedRegion(bed, name)
if err != nil {
t.Fatalf("region %q: %v", name, err)
}
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil); err != nil {
t.Fatalf("fill %q: %v", name, err)
}
}
fill("ne", garlic.ID)
fill("nw", basil.ID)
fill("south", beans.ID)
desc, err := s.DescribeGarden(ctx, owner, g.ID)
if err != nil {
t.Fatalf("DescribeGarden: %v", err)
}
if len(desc.Objects) != 1 {
t.Fatalf("objects = %d, want 1", len(desc.Objects))
}
// Tally plant → the set of rough locations it appears in.
locs := map[string]map[string]bool{}
for _, p := range desc.Objects[0].Plantings {
if locs[p.Plant] == nil {
locs[p.Plant] = map[string]bool{}
}
locs[p.Plant][p.Location] = true
}
if len(locs["Garlic"]) == 0 || !locs["Garlic"]["NE corner"] {
t.Errorf("garlic locations = %v, want NE corner", locs["Garlic"])
}
if !locs["Basil"]["NW corner"] {
t.Errorf("basil locations = %v, want NW corner", locs["Basil"])
}
// Beans fill the south half → their plops read as "south" (and possibly the
// SE/SW corners at the edges), never north.
for loc := range locs["Beans"] {
if loc == "north" || loc == "NE corner" || loc == "NW corner" || loc == "center" {
t.Errorf("beans appeared in %q, want only southern locations", loc)
}
}
if len(locs["Beans"]) == 0 {
t.Error("beans produced no plops")
}
}
// seedNamedPlant creates a custom plant with a specific name + spacing.
func seedNamedPlant(t *testing.T, s *Service, owner int64, name string, spacingCM float64) *domain.Plant {
t.Helper()
p, err := s.CreatePlant(context.Background(), owner, PlantInput{
Name: name, Category: domain.CategoryVegetable, SpacingCM: spacingCM, Color: "#4a7c3f", Icon: "🌱",
})
if err != nil {
t.Fatalf("seed plant %s: %v", name, err)
}
return p
}