Files
pansy/internal/agent/tools_test.go
T
steveandClaude Opus 4.8 d3b7dd06c9
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
Add agent seam: bulk ops + majordomo tool wrappers (#19)
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
2026-07-19 00:54:03 -04:00

134 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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/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: "[email protected]", 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, _ := svc.CreatePlant(ctx, owner.ID, service.PlantInput{Name: "Garlic", Category: "vegetable", SpacingCM: 15, Color: "#d9d2c5", Icon: "🧄"})
basil, _ := svc.CreatePlant(ctx, owner.ID, service.PlantInput{Name: "Basil", Category: "herb", SpacingCM: 25, Color: "#4a7c3f", Icon: "🌿"})
beans, _ := svc.CreatePlant(ctx, owner.ID, service.PlantInput{Name: "Beans", Category: "vegetable", SpacingCM: 10, Color: "#6b8e23", Icon: "🫘"})
// 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, _ := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "V", Password: "password123"})
if _, err := svc.AddShare(ctx, owner.ID, g.ID, "[email protected]", "viewer"); 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(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(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}