Agent tools: plant lookup, plant creation, journal entries (#55)
"Change the garlic garden bed to instead be cucumbers this year" was nearly executable already — describe_garden resolves "the garlic bed" to an object id because it reports the NAMES of what's planted, clear_object empties it, and fill_region replants it. Step three had no way to get its plantId. The toolbox had no plant tool at all, so the agent could see the string "Garlic" come out of describe_garden and had no route from "cucumbers" to an id. One missing tool blocked the whole flagship interaction. find_plant matches the actor's visible catalog and deliberately returns SEVERAL candidates rather than one guess. "garlic" against a catalog holding both "Garlic" and "German Red Garlic" is genuinely ambiguous, and a caller holding the surrounding conversation is far better placed to disambiguate than a fuzzy-match heuristic here. Results are ranked exact, then prefix, then substring, then category, and ordered stably — a tool whose results reshuffle between calls is one a model can't reason about across turns. Each result also reports how much seed is left of that plant across the actor's lots, so the agent can say "you only have enough for half that bed" instead of confidently planting seed that doesn't exist. Omitted rather than zeroed when there are no lots, and dropped when lots disagree about the unit, since summing seeds and grams would be a lie. create_plant lets a variety be named in conversation without leaving the chat. It's user-scoped, not garden-scoped — someone with no editable garden can still name a plant — which the tests assert deliberately rather than assume. add_journal_entry arrived with #52: "note that the west bed has mildew" is squarely the kind of thing you say out loud while walking around. Also reviewed the descriptions on the existing seven tools, since they are the model's only documentation. fill_region's region vocabulary now says north is the top of the garden, gives a worked example of the replant sequence, and notes that filling twice is safe. The demo sequence is now a test: describe_garden → find_plant("cucumber") → clear_object → fill_region, ending with a bed of cucumbers and no active garlic. go.mod is deliberately untouched — majordomo stays out of the module until #56 drops the build tag, so the default build is unaffected. Closes #55 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -5,6 +5,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
@@ -150,3 +151,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