Agent tools: plant lookup, plant creation, journal entries (#55) (#69)
Build image / build-and-push (push) Successful in 11s
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #69.
This commit is contained in:
+238
-20
@@ -5,6 +5,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
@@ -24,21 +25,8 @@ import (
|
||||
// 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)
|
||||
svc, ownerID := newAgentTestService(t)
|
||||
box := NewToolbox(svc, ownerID)
|
||||
|
||||
call := func(name string, args any) llm.ToolResult {
|
||||
t.Helper()
|
||||
@@ -48,13 +36,13 @@ func TestToolboxScenario(t *testing.T) {
|
||||
|
||||
// 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})
|
||||
g, err := svc.CreateGarden(ctx, ownerID, 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, "🫘")
|
||||
garlic := mustPlant(t, svc, ownerID, "Garlic", 15, "🧄")
|
||||
basil := mustPlant(t, svc, ownerID, "Basil", 25, "🌿")
|
||||
beans := mustPlant(t, svc, ownerID, "Beans", 10, "🫘")
|
||||
|
||||
// create_object → a 400×400 bed.
|
||||
res := call("create_object", map[string]any{
|
||||
@@ -119,7 +107,7 @@ func TestToolboxScenario(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("register viewer: %v", err)
|
||||
}
|
||||
if _, err := svc.AddShare(ctx, owner.ID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||
if _, err := svc.AddShare(ctx, ownerID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||
t.Fatalf("share: %v", err)
|
||||
}
|
||||
viewerBox := NewToolbox(svc, viewerUser.ID)
|
||||
@@ -150,3 +138,233 @@ func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spa
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestGarlicBedToCucumbers is the flagship interaction from #58, driven end to
|
||||
// end through the tool layer: "change the garlic garden bed to instead be
|
||||
// cucumbers this year".
|
||||
//
|
||||
// The sequence is the one a model would actually run — describe to find the bed,
|
||||
// find_plant to turn the word "cucumber" into an id, clear, refill — and until
|
||||
// find_plant existed step three had no way to get its plantId, which blocked the
|
||||
// whole thing on one missing tool.
|
||||
func TestGarlicBedToCucumbers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
box := NewToolbox(svc, owner)
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
|
||||
mustPlant(t, svc, owner, "Cucumber", 45, "🥒")
|
||||
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||
Kind: domain.KindBed, Name: "Garlic bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
|
||||
t.Fatalf("seed the garlic: %v", err)
|
||||
}
|
||||
|
||||
// 1. describe_garden — "the garlic bed" resolves to an object id, because the
|
||||
// description carries the NAMES of what's planted in each object.
|
||||
res := call("describe_garden", map[string]any{"gardenId": g.ID})
|
||||
if res.IsError {
|
||||
t.Fatalf("describe_garden: %s", res.Content)
|
||||
}
|
||||
if !strings.Contains(res.Content, "Garlic") {
|
||||
t.Fatalf("describe_garden didn't name what's planted: %s", res.Content)
|
||||
}
|
||||
|
||||
// 2. find_plant — the step that used to be impossible.
|
||||
res = call("find_plant", map[string]any{"query": "cucumber"})
|
||||
if res.IsError {
|
||||
t.Fatalf("find_plant: %s", res.Content)
|
||||
}
|
||||
var matches []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(res.Content), &matches); err != nil {
|
||||
t.Fatalf("decode find_plant: %v (%s)", err, res.Content)
|
||||
}
|
||||
if len(matches) == 0 || matches[0].Name != "Cucumber" {
|
||||
t.Fatalf("find_plant(cucumber) = %+v", matches)
|
||||
}
|
||||
|
||||
// 3. clear_object, 4. fill_region.
|
||||
if r := call("clear_object", map[string]any{"objectId": bed.ID}); r.IsError {
|
||||
t.Fatalf("clear_object: %s", r.Content)
|
||||
}
|
||||
if r := call("fill_region", map[string]any{
|
||||
"objectId": bed.ID, "region": "all", "plantId": matches[0].ID,
|
||||
}); r.IsError {
|
||||
t.Fatalf("fill_region: %s", r.Content)
|
||||
}
|
||||
|
||||
// The bed is cucumbers, and no garlic is still growing in it.
|
||||
full, err := svc.GardenFull(ctx, owner, g.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GardenFull: %v", err)
|
||||
}
|
||||
if len(full.Plantings) == 0 {
|
||||
t.Fatal("the bed ended up empty")
|
||||
}
|
||||
for _, p := range full.Plantings {
|
||||
if p.PlantID == garlic.ID {
|
||||
t.Errorf("garlic is still active in the bed")
|
||||
}
|
||||
if p.PlantID != matches[0].ID {
|
||||
t.Errorf("unexpected plant %d in the bed", p.PlantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindPlantReturnsCandidatesNotAGuess — "garlic" against a catalog holding
|
||||
// both "Garlic" and "German Red Garlic" is genuinely ambiguous, and silently
|
||||
// picking one is how the agent plants the wrong thing.
|
||||
func TestFindPlantReturnsCandidatesNotAGuess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
box := NewToolbox(svc, owner)
|
||||
|
||||
mustPlant(t, svc, owner, "German Red Garlic", 15, "🧄")
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{"query": "garlic"})
|
||||
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "find_plant", Arguments: raw})
|
||||
if res.IsError {
|
||||
t.Fatalf("find_plant: %s", res.Content)
|
||||
}
|
||||
var matches []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(res.Content), &matches); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, m := range matches {
|
||||
names[m.Name] = true
|
||||
}
|
||||
// The built-in catalog seeds a plain "Garlic"; the custom one is ours.
|
||||
if !names["Garlic"] || !names["German Red Garlic"] {
|
||||
t.Errorf("find_plant(garlic) = %+v, want both the built-in and the custom variety", matches)
|
||||
}
|
||||
// Exact match ranks first, so a caller taking [0] gets the least surprising one.
|
||||
if len(matches) > 0 && matches[0].Name != "Garlic" {
|
||||
t.Errorf("first match = %q, want the exact name", matches[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePlantIsUserScoped — the catalog belongs to the user, not to any
|
||||
// garden, so someone with no editable garden can still name a new variety. The
|
||||
// issue asks for this to be asserted rather than assumed.
|
||||
func TestCreatePlantIsUserScoped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
other, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "B", Password: "password123"})
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
box := NewToolbox(svc, other.ID)
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"name": "Painted Mountain Corn", "category": "vegetable",
|
||||
"spacingCm": 30.0, "color": "#c08a3f", "icon": "🌽",
|
||||
})
|
||||
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "create_plant", Arguments: raw})
|
||||
if res.IsError {
|
||||
t.Fatalf("create_plant: %s", res.Content)
|
||||
}
|
||||
var created struct {
|
||||
ID int64 `json:"id"`
|
||||
OwnerID *int64 `json:"ownerId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(res.Content), &created); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if created.OwnerID == nil || *created.OwnerID != other.ID {
|
||||
t.Errorf("ownerId = %v, want the acting user %d", created.OwnerID, other.ID)
|
||||
}
|
||||
// And it stays theirs: the other user's catalog doesn't gain it.
|
||||
ownerPlants, err := svc.ListPlants(ctx, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPlants: %v", err)
|
||||
}
|
||||
for _, p := range ownerPlants {
|
||||
if p.Name == "Painted Mountain Corn" {
|
||||
t.Error("a plant created by one user showed up in another's catalog")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJournalToolWritesADatedObservation — "note that the west bed has mildew"
|
||||
// is squarely the kind of thing you say out loud while walking around.
|
||||
func TestJournalToolWritesADatedObservation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
box := NewToolbox(svc, owner)
|
||||
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||
Kind: domain.KindBed, Name: "West bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"gardenId": g.ID, "objectId": bed.ID,
|
||||
"body": "Powdery mildew on the west bed", "observedAt": "2026-08-14",
|
||||
})
|
||||
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "add_journal_entry", Arguments: raw})
|
||||
if res.IsError {
|
||||
t.Fatalf("add_journal_entry: %s", res.Content)
|
||||
}
|
||||
|
||||
entries, _, err := svc.ListJournal(ctx, owner, g.ID, service.JournalQuery{ObjectID: &bed.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListJournal: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("got %d entries, want 1", len(entries))
|
||||
}
|
||||
if entries[0].Body != "Powdery mildew on the west bed" || entries[0].ObservedAt != "2026-08-14" {
|
||||
t.Errorf("unexpected entry: %+v", entries[0])
|
||||
}
|
||||
if entries[0].AuthorID != owner {
|
||||
t.Errorf("author = %d, want the acting user %d", entries[0].AuthorID, owner)
|
||||
}
|
||||
}
|
||||
|
||||
// newAgentTestService spins up an in-memory pansy with one registered user.
|
||||
func newAgentTestService(t *testing.T) (*service.Service, int64) {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
return svc, owner.ID
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user