- update_seed_lot / delete_seed_lot: correct or drop a recorded purchase
("it was three packets, not two"); the plant a lot is for stays fixed.
- delete_plant: remove a duplicate from the user's catalog. The service
already refuses while plantings (past seasons included) or a lot reference
it; the tool turns that sentinel into words the model can pass on, and
tells it not to clear those references to get its way.
- create_garden: a new place, with the service's defaults; the prompt says a
plan is still a copy_garden.
- describe_garden groups carry readyAround — planting date plus days to
maturity for the plops still in the ground — so "what can I pick this
week?" is a lookup rather than arithmetic the model got wrong live.
Co-Authored-By: Claude Fable 5 <[email protected]>
1031 lines
44 KiB
Go
1031 lines
44 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"sort"
|
||
"strings"
|
||
"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()
|
||
svc, ownerID := newAgentTestService(t)
|
||
box := NewToolbox(svc, ownerID, "")
|
||
|
||
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, ownerID, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||
if err != nil {
|
||
t.Fatalf("garden: %v", err)
|
||
}
|
||
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{
|
||
"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))
|
||
}
|
||
// Plantings come grouped by plant: a group's Where names the region when the
|
||
// whole group sits in one, and a small group also lists its plops.
|
||
seen := map[string]map[string]bool{}
|
||
for _, g := range desc.Objects[0].Plantings {
|
||
if seen[g.Plant] == nil {
|
||
seen[g.Plant] = map[string]bool{}
|
||
}
|
||
seen[g.Plant][g.Where] = true
|
||
for _, p := range g.Each {
|
||
seen[g.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: "[email protected]", DisplayName: "V", Password: "password123"})
|
||
if err != nil {
|
||
t.Fatalf("register viewer: %v", err)
|
||
}
|
||
if _, err := svc.AddShare(ctx, ownerID, g.ID, "[email protected]", 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
|
||
}
|
||
|
||
// 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, service.FillClump, 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)
|
||
}
|
||
}
|
||
|
||
// TestCorrectiveTools covers the #85 gaps: the agent can now read the journal it
|
||
// could only write, resize and delete an object it could only create and move,
|
||
// pull a single plop instead of clearing the whole bed, and record/read seed
|
||
// lots. Each is driven through the tool layer the way a model would run it.
|
||
func TestCorrectiveTools(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc, owner := newAgentTestService(t)
|
||
box := NewToolbox(svc, owner, "")
|
||
|
||
var gid int64 // set once the garden exists; the describe closure reads it.
|
||
call := func(name string, args any) llm.ToolResult {
|
||
t.Helper()
|
||
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
|
||
}
|
||
describe := func() service.DescribeResult {
|
||
t.Helper()
|
||
res := call("describe_garden", map[string]any{"gardenId": gid})
|
||
if res.IsError {
|
||
t.Fatalf("describe_garden: %s", res.Content)
|
||
}
|
||
var d service.DescribeResult
|
||
if err := json.Unmarshal([]byte(res.Content), &d); err != nil {
|
||
t.Fatalf("decode describe: %v", err)
|
||
}
|
||
return d
|
||
}
|
||
|
||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||
if err != nil {
|
||
t.Fatalf("garden: %v", err)
|
||
}
|
||
gid = g.ID
|
||
basil := mustPlant(t, svc, owner, "Basil", 25, "🌿")
|
||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("bed: %v", err)
|
||
}
|
||
|
||
// update_object: "make that bed 100cm wider" — read the version, pass a new width.
|
||
d := describe()
|
||
if r := call("update_object", map[string]any{
|
||
"objectId": bed.ID, "version": d.Objects[0].Version, "widthCm": 500.0,
|
||
}); r.IsError {
|
||
t.Fatalf("update_object: %s", r.Content)
|
||
}
|
||
if w := describe().Objects[0].WidthCM; w != 500 {
|
||
t.Errorf("width = %v after update_object, want 500", w)
|
||
}
|
||
|
||
// place a plop, then remove_planting it by id+version — one plop, not the bed.
|
||
if r := call("place_planting", map[string]any{
|
||
"objectId": bed.ID, "plantId": basil.ID, "xCm": 0, "yCm": 0, "radiusCm": 30,
|
||
}); r.IsError {
|
||
t.Fatalf("place_planting: %s", r.Content)
|
||
}
|
||
d = describe()
|
||
if len(d.Objects[0].Plantings) != 1 || len(d.Objects[0].Plantings[0].Each) != 1 {
|
||
t.Fatalf("want 1 plop before removal, got %+v", d.Objects[0].Plantings)
|
||
}
|
||
plop := d.Objects[0].Plantings[0].Each[0]
|
||
if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError {
|
||
t.Fatalf("remove_planting: %s", r.Content)
|
||
}
|
||
if n := len(describe().Objects[0].Plantings); n != 0 {
|
||
t.Errorf("want 0 active plops after remove_planting, got %d", n)
|
||
}
|
||
|
||
// add then read the journal — the write/read asymmetry the issue flagged.
|
||
if r := call("add_journal_entry", map[string]any{
|
||
"gardenId": g.ID, "objectId": bed.ID, "body": "aphids", "observedAt": "2026-06-01",
|
||
}); r.IsError {
|
||
t.Fatalf("add_journal_entry: %s", r.Content)
|
||
}
|
||
res := call("read_journal", map[string]any{"gardenId": g.ID, "objectId": bed.ID})
|
||
if res.IsError {
|
||
t.Fatalf("read_journal: %s", res.Content)
|
||
}
|
||
var jr struct {
|
||
Entries []struct {
|
||
Body string `json:"body"`
|
||
} `json:"entries"`
|
||
}
|
||
if err := json.Unmarshal([]byte(res.Content), &jr); err != nil {
|
||
t.Fatalf("decode read_journal: %v (%s)", err, res.Content)
|
||
}
|
||
if len(jr.Entries) != 1 || jr.Entries[0].Body != "aphids" {
|
||
t.Errorf("read_journal = %+v, want the one aphids entry", jr.Entries)
|
||
}
|
||
|
||
// record then list a seed lot — the detail behind find_plant's "remaining".
|
||
if r := call("record_seed_lot", map[string]any{
|
||
"plantId": basil.ID, "quantity": 2.0, "unit": "packets", "vendor": "Johnny's",
|
||
}); r.IsError {
|
||
t.Fatalf("record_seed_lot: %s", r.Content)
|
||
}
|
||
res = call("list_seed_lots", map[string]any{"plantId": basil.ID})
|
||
if res.IsError {
|
||
t.Fatalf("list_seed_lots: %s", res.Content)
|
||
}
|
||
var lots []struct {
|
||
Quantity float64 `json:"quantity"`
|
||
Unit string `json:"unit"`
|
||
}
|
||
if err := json.Unmarshal([]byte(res.Content), &lots); err != nil {
|
||
t.Fatalf("decode list_seed_lots: %v (%s)", err, res.Content)
|
||
}
|
||
if len(lots) != 1 || lots[0].Quantity != 2 || lots[0].Unit != "packets" {
|
||
t.Errorf("list_seed_lots = %+v, want one lot of 2 packets", lots)
|
||
}
|
||
|
||
// delete_object: the counterpart to create_object.
|
||
if r := call("delete_object", map[string]any{"objectId": bed.ID}); r.IsError {
|
||
t.Fatalf("delete_object: %s", r.Content)
|
||
}
|
||
if n := len(describe().Objects); n != 0 {
|
||
t.Errorf("want 0 objects after delete_object, got %d", n)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// TestToolsFromTheLiveSweep covers what a day of driving the live assistant
|
||
// asked for: grouped describes, whole-group removal, moves that keep the
|
||
// planting date, fills by rectangle, seed attribution, catalog edits, history
|
||
// reads, plan copies — and every date stamped the gardener's local day rather
|
||
// than the server's (UTC) or the model's (a year from its training data).
|
||
func TestToolsFromTheLiveSweep(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc, owner := newAgentTestService(t)
|
||
const today = "2026-08-22"
|
||
box := NewToolbox(svc, owner, today)
|
||
call := func(name string, args any) llm.ToolResult {
|
||
t.Helper()
|
||
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
|
||
}
|
||
ok := func(name string, args any) string {
|
||
t.Helper()
|
||
r := call(name, args)
|
||
if r.IsError {
|
||
t.Fatalf("%s: %s", name, r.Content)
|
||
}
|
||
return r.Content
|
||
}
|
||
decode := func(raw string, into any) {
|
||
t.Helper()
|
||
if err := json.Unmarshal([]byte(raw), into); err != nil {
|
||
t.Fatalf("decode %v: %s", err, raw)
|
||
}
|
||
}
|
||
|
||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000, UnitPref: domain.UnitImperial})
|
||
if err != nil {
|
||
t.Fatalf("garden: %v", err)
|
||
}
|
||
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
|
||
beet := mustPlant(t, svc, owner, "Beet", 10, "🌱")
|
||
tomato := mustPlant(t, svc, owner, "Cherokee Purple", 60, "🍅")
|
||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "South bed", XCM: 1000, YCM: 1000, WidthCM: 240, HeightCM: 120})
|
||
if err != nil {
|
||
t.Fatalf("bed: %v", err)
|
||
}
|
||
other, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "North bed", XCM: 1000, YCM: 300, WidthCM: 240, HeightCM: 120})
|
||
if err != nil {
|
||
t.Fatalf("other bed: %v", err)
|
||
}
|
||
lot, err := svc.CreateSeedLot(ctx, owner, service.SeedLotInput{PlantID: beet.ID, Quantity: 500, Unit: "seeds"})
|
||
if err != nil {
|
||
t.Fatalf("lot: %v", err)
|
||
}
|
||
|
||
// fill_region by rectangle (the middle third of the bed's width), in grid mode,
|
||
// charged to the lot, dated today by default.
|
||
ok("fill_region", map[string]any{
|
||
"objectId": bed.ID, "plantId": beet.ID, "mode": "grid", "seedLotId": lot.ID,
|
||
"x0Cm": -40.0, "y0Cm": -60.0, "x1Cm": 40.0, "y1Cm": 60.0,
|
||
})
|
||
// Neither a region nor a full rectangle is a mistake the model can read.
|
||
if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "x0Cm": -40.0}); !r.IsError || !strings.Contains(r.Content, "x0Cm, y0Cm, x1Cm, y1Cm") {
|
||
t.Errorf("half a rectangle: %+v, want a readable refusal", r)
|
||
}
|
||
if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID}); !r.IsError {
|
||
t.Error("fill_region with nowhere to fill succeeded")
|
||
}
|
||
// An inverted rectangle is refused with its corners named, before the service
|
||
// sees it — the mistake a model makes is swapping which way is north.
|
||
if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "x0Cm": 40.0, "y0Cm": -60.0, "x1Cm": -40.0, "y1Cm": 60.0}); !r.IsError || !strings.Contains(r.Content, "west of") {
|
||
t.Errorf("inverted rectangle: %+v, want a refusal naming the corners", r)
|
||
}
|
||
// remove_plantings without a plant would "remove" plant 0 — nothing.
|
||
if r := call("remove_plantings", map[string]any{"objectId": bed.ID}); !r.IsError || !strings.Contains(r.Content, "plantId") {
|
||
t.Errorf("remove_plantings with no plant: %+v, want a refusal asking which plant", r)
|
||
}
|
||
// place_planting without a radius → one plant at half the spacing; two garlic
|
||
// cloves along the north edge, dated today.
|
||
ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": garlic.ID, "xCm": -100, "yCm": -50})
|
||
ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": garlic.ID, "xCm": 100, "yCm": -50})
|
||
// And a tomato planted back in May, with an explicit date.
|
||
ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": tomato.ID, "xCm": 0, "yCm": 0, "plantedAt": "2026-05-20"})
|
||
|
||
// describe_garden: one group per plant, dated; only the small ones listed.
|
||
var d service.DescribeResult
|
||
groups := func() map[string]service.DescribeGroup {
|
||
t.Helper()
|
||
decode(ok("describe_garden", map[string]any{"gardenId": g.ID}), &d)
|
||
out := map[string]service.DescribeGroup{}
|
||
for _, o := range d.Objects {
|
||
if o.ID == bed.ID {
|
||
for _, gr := range o.Plantings {
|
||
out[gr.Plant] = gr
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
gs := groups()
|
||
beets := gs["Beet"]
|
||
if beets.Plops <= 8 || beets.Each != nil {
|
||
t.Errorf("beets: %d plops, each=%v; want a large group with no per-plop listing", beets.Plops, beets.Each)
|
||
}
|
||
if beets.PlantedAt != today || beets.Plants != beets.Plops {
|
||
t.Errorf("beets plantedAt %q plants %d; want today and one plant per grid plop", beets.PlantedAt, beets.Plants)
|
||
}
|
||
if !strings.Contains(beets.Where, "cm from the centre") {
|
||
t.Errorf("beets where = %q, want the bounding box of a middle-third fill", beets.Where)
|
||
}
|
||
cloves := gs["Garlic"]
|
||
if cloves.Plops != 2 || len(cloves.Each) != 2 || cloves.Where != "north half" || cloves.PlantedAt != today {
|
||
t.Errorf("garlic group = %+v, want 2 listed plops in the north half, dated today", cloves)
|
||
}
|
||
if r := cloves.Each[0].RadiusCM; r != 7.5 {
|
||
t.Errorf("a clove placed without a radius got %v, want spacing/2 = 7.5", r)
|
||
}
|
||
tom := gs["Cherokee Purple"]
|
||
if tom.Plops != 1 || tom.Where != "center" || tom.PlantedAt != "2026-05-20" {
|
||
t.Errorf("tomato group = %+v, want one plop at the center dated 2026-05-20", tom)
|
||
}
|
||
|
||
// The lot counts the beets as used.
|
||
var lots []struct {
|
||
Used float64 `json:"used"`
|
||
Remaining float64 `json:"remaining"`
|
||
}
|
||
decode(ok("list_seed_lots", map[string]any{"plantId": beet.ID}), &lots)
|
||
if len(lots) != 1 || lots[0].Used != float64(beets.Plants) || lots[0].Remaining != 500-float64(beets.Plants) {
|
||
t.Errorf("lots = %+v, want %d used of 500", lots, beets.Plants)
|
||
}
|
||
|
||
// list_plantings spells the big group out, narrowed to one plant.
|
||
var listed []service.DescribePlanting
|
||
decode(ok("list_plantings", map[string]any{"objectId": bed.ID, "plantId": beet.ID}), &listed)
|
||
if len(listed) != beets.Plops {
|
||
t.Errorf("list_plantings: %d beets, want %d", len(listed), beets.Plops)
|
||
}
|
||
|
||
// move_planting: the tomato to the north bed, date kept; a within-bed move too.
|
||
var moved domain.Planting
|
||
decode(ok("move_planting", map[string]any{
|
||
"plantingId": tom.Each[0].ID, "version": tom.Each[0].Version, "toObjectId": other.ID, "xCm": 10.0, "yCm": -20.0,
|
||
}), &moved)
|
||
if moved.ObjectID != other.ID || moved.PlantedAt == nil || *moved.PlantedAt != "2026-05-20" {
|
||
t.Errorf("moved tomato = %+v, want it in the north bed with its May date", moved)
|
||
}
|
||
decode(ok("move_planting", map[string]any{
|
||
"plantingId": cloves.Each[0].ID, "version": cloves.Each[0].Version, "xCm": -110.0, "yCm": -55.0,
|
||
}), &moved)
|
||
if moved.ObjectID != bed.ID || moved.XCM != -110 {
|
||
t.Errorf("within-bed move = %+v, want the same bed at x=-110", moved)
|
||
}
|
||
|
||
// remove_plantings: the beets out, the garlic stays — dated today.
|
||
var removed struct {
|
||
Removed int `json:"removed"`
|
||
}
|
||
decode(ok("remove_plantings", map[string]any{"objectId": bed.ID, "plantId": beet.ID}), &removed)
|
||
if removed.Removed != beets.Plops {
|
||
t.Errorf("remove_plantings removed %d, want the %d beets", removed.Removed, beets.Plops)
|
||
}
|
||
gs = groups()
|
||
if _, still := gs["Beet"]; still || gs["Garlic"].Plops != 2 {
|
||
t.Errorf("after remove_plantings the bed has %+v, want the garlic only", gs)
|
||
}
|
||
var pulled domain.Planting
|
||
decode(ok("remove_planting", map[string]any{"plantingId": gs["Garlic"].Each[0].ID, "version": gs["Garlic"].Each[0].Version}), &pulled)
|
||
if pulled.RemovedAt == nil || *pulled.RemovedAt != today {
|
||
t.Errorf("remove_planting dated the removal %v, want today %s", pulled.RemovedAt, today)
|
||
}
|
||
|
||
// read_history sees all of that, newest first, and marks what was undone.
|
||
var hist struct {
|
||
Entries []historyEntry `json:"entries"`
|
||
HasMore bool `json:"hasMore"`
|
||
}
|
||
decode(ok("read_history", map[string]any{"gardenId": g.ID, "limit": 3}), &hist)
|
||
if len(hist.Entries) != 3 || !hist.HasMore {
|
||
t.Fatalf("read_history = %d entries, hasMore=%v; want 3 and more", len(hist.Entries), hist.HasMore)
|
||
}
|
||
if e := hist.Entries[1]; !strings.HasPrefix(e.Summary, "Removed Beet from South bed") || !strings.Contains(e.Changes, "planting") || e.Undone {
|
||
t.Errorf("entry = %+v, want the beet removal, not undone", e)
|
||
}
|
||
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, hist.Entries[1].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
|
||
}
|
||
decode(ok("read_history", map[string]any{"gardenId": g.ID, "limit": 3}), &hist)
|
||
if hist.Entries[0].UndoOf == nil || !hist.Entries[2].Undone {
|
||
t.Errorf("after an undo: newest = %+v, undone = %+v; want the revert to point at the removal, and the removal marked undone", hist.Entries[0], hist.Entries[2])
|
||
}
|
||
|
||
// update_plant on the user's own plant; a built-in is refused.
|
||
var matches []struct {
|
||
ID int64 `json:"id"`
|
||
Version int64 `json:"version"`
|
||
}
|
||
decode(ok("find_plant", map[string]any{"query": "cherokee"}), &matches)
|
||
var updated domain.Plant
|
||
decode(ok("update_plant", map[string]any{"plantId": matches[0].ID, "version": matches[0].Version, "daysToMaturity": 75}), &updated)
|
||
if updated.DaysToMaturity == nil || *updated.DaysToMaturity != 75 || updated.Name != "Cherokee Purple" {
|
||
t.Errorf("update_plant = %+v, want days 75 and the name untouched", updated)
|
||
}
|
||
decode(ok("find_plant", map[string]any{"query": "basil"}), &matches)
|
||
if r := call("update_plant", map[string]any{"plantId": matches[0].ID, "version": matches[0].Version, "daysToMaturity": 60}); !r.IsError {
|
||
t.Error("update_plant changed a built-in")
|
||
}
|
||
|
||
// add_journal_entry is dated today unless told otherwise.
|
||
ok("add_journal_entry", map[string]any{"gardenId": g.ID, "body": "aphids on the beets"})
|
||
entries, _, err := svc.ListJournal(ctx, owner, g.ID, service.JournalQuery{})
|
||
if err != nil {
|
||
t.Fatalf("ListJournal: %v", err)
|
||
}
|
||
if len(entries) != 1 || entries[0].ObservedAt != today {
|
||
t.Errorf("journal = %+v, want one entry observed %s", entries, today)
|
||
}
|
||
|
||
// copy_garden makes next year's plan: a whole copy under the plan name.
|
||
var plan domain.Garden
|
||
decode(ok("copy_garden", map[string]any{"gardenId": g.ID, "name": "Plot — 2027"}), &plan)
|
||
if plan.Name != "Plot — 2027" || plan.ID == g.ID {
|
||
t.Errorf("copy_garden = %+v, want a new garden named for the plan", plan)
|
||
}
|
||
decode(ok("describe_garden", map[string]any{"gardenId": plan.ID}), &d)
|
||
if len(d.Objects) != 2 {
|
||
t.Errorf("the plan copy has %d objects, want the source's 2", len(d.Objects))
|
||
}
|
||
}
|
||
|
||
// TestToolsDefaultToTheServiceDayWithoutOne — a toolbox built with no local day
|
||
// (a bare API caller) still dates everything: the service's UTC today.
|
||
func TestToolsDefaultToTheServiceDayWithoutOne(t *testing.T) {
|
||
a := &adapter{today: ""}
|
||
if d, err := a.day(""); d != nil || err != nil {
|
||
t.Errorf("no day at all → %v, %v; want nil (the service default)", d, err)
|
||
}
|
||
if d, err := a.day(" 2026-01-02 "); err != nil || d == nil || *d != "2026-01-02" {
|
||
t.Errorf("an explicit day → %v, %v; want it trimmed", d, err)
|
||
}
|
||
if d, err := a.day("Tuesday"); err == nil || !strings.Contains(err.Error(), "YYYY-MM-DD") {
|
||
t.Errorf("a prose day → %v, %v; want a refusal naming the format", d, err)
|
||
}
|
||
a.today = "2026-08-22"
|
||
if d, _ := a.day(""); d == nil || *d != "2026-08-22" {
|
||
t.Errorf("the gardener's day → %v, want 2026-08-22", d)
|
||
}
|
||
if d, _ := a.day("2026-05-20"); d == nil || *d != "2026-05-20" {
|
||
t.Errorf("an explicit day beats the default: %v", d)
|
||
}
|
||
}
|
||
|
||
// TestRecordKeepingTools covers the tools that correct the record rather than
|
||
// change the garden — and the one that undoes a change for real. Each answers a
|
||
// thing the live assistant could not do: fix a planting date, backdate a
|
||
// harvest, correct a journal note, remember the gardener's zone, see last
|
||
// season, and undo without pretending.
|
||
func TestRecordKeepingTools(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc, owner := newAgentTestService(t)
|
||
box := NewToolbox(svc, owner, "2026-08-23")
|
||
|
||
call := func(name string, args any) llm.ToolResult {
|
||
t.Helper()
|
||
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
|
||
}
|
||
mustCall := func(name string, args any, into any) {
|
||
t.Helper()
|
||
res := call(name, args)
|
||
if res.IsError {
|
||
t.Fatalf("%s: %s", name, res.Content)
|
||
}
|
||
if into != nil {
|
||
if err := json.Unmarshal([]byte(res.Content), into); err != nil {
|
||
t.Fatalf("decode %s: %v (%s)", name, err, res.Content)
|
||
}
|
||
}
|
||
}
|
||
|
||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Home", WidthCM: 1200, HeightCM: 800, Notes: "Zone 6a."})
|
||
if err != nil {
|
||
t.Fatalf("garden: %v", err)
|
||
}
|
||
beet := mustPlant(t, svc, owner, "Beet", 10, "")
|
||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "South bed", XCM: 600, YCM: 400, WidthCM: 400, HeightCM: 200})
|
||
if err != nil {
|
||
t.Fatalf("bed: %v", err)
|
||
}
|
||
|
||
// --- update_garden: one field changes, the rest survive, notes merge by hand.
|
||
var desc service.DescribeResult
|
||
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
|
||
if desc.Notes != "Zone 6a." || desc.Version != g.Version {
|
||
t.Fatalf("describe carries notes %q v%d, want %q v%d", desc.Notes, desc.Version, "Zone 6a.", g.Version)
|
||
}
|
||
if r := call("update_garden", map[string]any{"gardenId": g.ID, "version": desc.Version}); !r.IsError || !strings.Contains(r.Content, "what to change") {
|
||
t.Errorf("update_garden with nothing to change = %q, want a refusal that says so", r.Content)
|
||
}
|
||
var updated domain.Garden
|
||
mustCall("update_garden", map[string]any{
|
||
"gardenId": g.ID, "version": desc.Version, "notes": desc.Notes + "\nLast frost is usually around May 10.",
|
||
}, &updated)
|
||
if updated.Name != "Home" || updated.WidthCM != 1200 || updated.HeightCM != 800 || updated.UnitPref != domain.UnitMetric {
|
||
t.Errorf("a notes-only update changed other fields: %+v", updated)
|
||
}
|
||
if !strings.HasPrefix(updated.Notes, "Zone 6a.") || !strings.Contains(updated.Notes, "May 10") {
|
||
t.Errorf("notes = %q, want the old note kept and the new line added", updated.Notes)
|
||
}
|
||
if r := call("update_garden", map[string]any{"gardenId": g.ID, "version": desc.Version, "name": "Stale"}); !r.IsError {
|
||
t.Error("update_garden with a stale version succeeded")
|
||
}
|
||
mustCall("update_garden", map[string]any{"gardenId": g.ID, "version": updated.Version, "units": "Imperial"}, &updated)
|
||
if updated.UnitPref != domain.UnitImperial {
|
||
t.Errorf("units = %q after asking for imperial", updated.UnitPref)
|
||
}
|
||
|
||
// --- update_planting: correct a plop's record without touching its position.
|
||
var plop domain.Planting
|
||
mustCall("place_planting", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "xCm": 50, "yCm": -30, "radiusCm": 20, "plantedAt": "2026-05-01"}, &plop)
|
||
mustCall("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "count": 5, "label": "from the market"}, &plop)
|
||
if plop.Count == nil || *plop.Count != 5 || plop.Label == nil || *plop.Label != "from the market" || plop.XCM != 50 {
|
||
t.Errorf("after count+label: %+v", plop)
|
||
}
|
||
// Decoded into a fresh value: a field the response omits must read as
|
||
// cleared, not as whatever the previous decode left in the pointer.
|
||
cleared, version := domain.Planting{}, plop.Version
|
||
mustCall("update_planting", map[string]any{"plantingId": plop.ID, "version": version, "clearCount": true, "plantedAt": "2026-05-20", "label": ""}, &cleared)
|
||
plop = cleared
|
||
if plop.Count != nil || plop.Label != nil || plop.PlantedAt == nil || *plop.PlantedAt != "2026-05-20" {
|
||
t.Errorf("after clearCount/plantedAt/empty label: %+v", plop)
|
||
}
|
||
if r := call("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "plantedAt": "May 20"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
|
||
t.Errorf("a prose date = %q, want a refusal naming the format", r.Content)
|
||
}
|
||
if r := call("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "count": 3, "clearCount": true}); !r.IsError {
|
||
t.Error("count and clearCount together were accepted")
|
||
}
|
||
|
||
// A padded date is stored clean, not refused downstream with a bare error.
|
||
mustCall("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "plantedAt": " 2026-05-20 "}, &plop)
|
||
if plop.PlantedAt == nil || *plop.PlantedAt != "2026-05-20" {
|
||
t.Errorf("padded plantedAt stored as %v", plop.PlantedAt)
|
||
}
|
||
|
||
// --- remove_planting on the day the gardener said, not today; a prose day
|
||
// is refused the same way every dated tool refuses one.
|
||
for _, tool := range []string{"remove_planting", "remove_plantings", "clear_object"} {
|
||
args := map[string]any{"plantingId": plop.ID, "version": plop.Version, "objectId": bed.ID, "plantId": beet.ID, "removedAt": "Aug 1"}
|
||
if r := call(tool, args); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
|
||
t.Errorf("%s with a prose removedAt = %q, want a refusal naming the format", tool, r.Content)
|
||
}
|
||
}
|
||
mustCall("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "removedAt": "2026-08-01"}, &plop)
|
||
if plop.RemovedAt == nil || *plop.RemovedAt != "2026-08-01" {
|
||
t.Errorf("removedAt = %v, want the harvest date 2026-08-01", plop.RemovedAt)
|
||
}
|
||
|
||
// --- the season view sees it, the live view does not.
|
||
var years struct{ Years []int }
|
||
mustCall("list_years", map[string]any{"gardenId": g.ID}, &years)
|
||
if len(years.Years) == 0 || years.Years[0] != 2026 {
|
||
t.Errorf("years = %v, want 2026 first", years.Years)
|
||
}
|
||
// A gardener whose local year is behind the data's gets it listed, in
|
||
// order — newest first holds even when theirs is the oldest.
|
||
raw := NewToolbox(svc, owner, "2024-12-31").Execute(ctx, llm.ToolCall{ID: "3", Name: "list_years", Arguments: mustJSON(t, map[string]any{"gardenId": g.ID})})
|
||
if err := json.Unmarshal([]byte(raw.Content), &years); err != nil || raw.IsError {
|
||
t.Fatalf("list_years for 2024: %v %s", err, raw.Content)
|
||
}
|
||
if !sort.SliceIsSorted(years.Years, func(i, j int) bool { return years.Years[i] > years.Years[j] }) || years.Years[len(years.Years)-1] != 2024 {
|
||
t.Errorf("years for a 2024 gardener = %v, want newest first with 2024 last", years.Years)
|
||
}
|
||
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
|
||
if len(desc.Objects[0].Plantings) != 0 {
|
||
t.Errorf("the live describe still lists the pulled beet: %+v", desc.Objects[0].Plantings)
|
||
}
|
||
mustCall("describe_garden", map[string]any{"gardenId": g.ID, "year": 2026}, &desc)
|
||
if desc.Year == nil || *desc.Year != 2026 || len(desc.Objects[0].Plantings) != 1 {
|
||
t.Fatalf("2026 describe = year %v, %d groups; want the beet group", desc.Year, len(desc.Objects[0].Plantings))
|
||
}
|
||
if gr := desc.Objects[0].Plantings[0]; gr.Removed != 1 || gr.RemovedAt != "2026-08-01" || gr.PlantedAt != "2026-05-20" {
|
||
t.Errorf("2026 beet group = %+v; want 1 removed 2026-08-01, planted 2026-05-20", gr)
|
||
}
|
||
|
||
// --- undo_change: the removal is the newest history entry; undoing it puts
|
||
// the beet back, as a change that is itself in the history and undoable.
|
||
var hist struct {
|
||
Entries []historyEntry `json:"entries"`
|
||
}
|
||
mustCall("read_history", map[string]any{"gardenId": g.ID, "limit": 5}, &hist)
|
||
if len(hist.Entries) == 0 || !strings.HasPrefix(hist.Entries[0].Summary, "Removed Beet") {
|
||
t.Fatalf("history[0] = %+v, want the beet's removal", hist.Entries)
|
||
}
|
||
removal := hist.Entries[0].ID
|
||
if r := call("undo_change", map[string]any{}); !r.IsError || !strings.Contains(r.Content, "read_history") {
|
||
t.Errorf("undo_change without an id = %q, want a refusal pointing at read_history", r.Content)
|
||
}
|
||
var undone undoResult
|
||
mustCall("undo_change", map[string]any{"changeSetId": removal}, &undone)
|
||
if undone.ChangeSet == nil || undone.UndoneID != removal || len(undone.Conflicts) != 0 || !strings.Contains(undone.Changes, "1 planting updated") {
|
||
t.Errorf("undo result = %+v; want a new change set, no conflicts, one planting updated", undone)
|
||
}
|
||
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
|
||
if len(desc.Objects[0].Plantings) != 1 || desc.Objects[0].Plantings[0].Each[0].ID != plop.ID {
|
||
t.Errorf("after the undo the beet is not back: %+v", desc.Objects[0].Plantings)
|
||
}
|
||
mustCall("read_history", map[string]any{"gardenId": g.ID, "limit": 5}, &hist)
|
||
if e := hist.Entries[0]; e.ID != *undone.ChangeSet || e.UndoOf == nil || *e.UndoOf != removal || e.Source != domain.SourceAgent {
|
||
t.Errorf("history[0] after undo = %+v; want the agent's revert of %d", e, removal)
|
||
}
|
||
if !hist.Entries[1].Undone {
|
||
t.Error("the removal is not marked undone")
|
||
}
|
||
if got := (&adapter{}).lastRevert(); got != nil {
|
||
t.Errorf("a fresh adapter remembers a revert: %v", *got)
|
||
}
|
||
|
||
// --- the journal: correct an entry in place, then delete it.
|
||
var entry domain.JournalEntry
|
||
mustCall("add_journal_entry", map[string]any{"gardenId": g.ID, "body": "Aphids on the cantaloupe.", "observedAt": "2026-08-20"}, &entry)
|
||
if r := call("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version}); !r.IsError {
|
||
t.Error("update_journal_entry with nothing to change succeeded")
|
||
}
|
||
if r := call("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version, "observedAt": "yesterday"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
|
||
t.Errorf("a prose date = %q, want a refusal naming the format", r.Content)
|
||
}
|
||
mustCall("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version, "body": "Aphids on the cucumbers.", "observedAt": "2026-08-19"}, &entry)
|
||
if entry.Body != "Aphids on the cucumbers." || entry.ObservedAt != "2026-08-19" {
|
||
t.Errorf("corrected entry = %+v", entry)
|
||
}
|
||
var journal struct {
|
||
Entries []domain.JournalEntry `json:"entries"`
|
||
}
|
||
mustCall("read_journal", map[string]any{"gardenId": g.ID}, &journal)
|
||
if len(journal.Entries) != 1 || journal.Entries[0].Body != "Aphids on the cucumbers." {
|
||
t.Errorf("journal after the correction = %+v, want the one corrected entry", journal.Entries)
|
||
}
|
||
mustCall("delete_journal_entry", map[string]any{"entryId": entry.ID}, nil)
|
||
mustCall("read_journal", map[string]any{"gardenId": g.ID}, &journal)
|
||
if len(journal.Entries) != 0 {
|
||
t.Errorf("journal after the delete = %+v, want empty", journal.Entries)
|
||
}
|
||
}
|
||
|
||
// TestCatalogAndGardenTools — the catalog side of the record: correct or delete
|
||
// a seed lot, delete a duplicate plant (refused while anything references it,
|
||
// in words the model can pass on), and start a new garden with sane defaults.
|
||
func TestCatalogAndGardenTools(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc, owner := newAgentTestService(t)
|
||
box := NewToolbox(svc, owner, "2026-08-23")
|
||
|
||
call := func(name string, args any) llm.ToolResult {
|
||
t.Helper()
|
||
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
|
||
}
|
||
mustCall := func(name string, args any, into any) {
|
||
t.Helper()
|
||
res := call(name, args)
|
||
if res.IsError {
|
||
t.Fatalf("%s: %s", name, res.Content)
|
||
}
|
||
if into != nil {
|
||
if err := json.Unmarshal([]byte(res.Content), into); err != nil {
|
||
t.Fatalf("decode %s: %v (%s)", name, err, res.Content)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- create_garden: defaults, then an imperial one with notes.
|
||
var g domain.Garden
|
||
mustCall("create_garden", map[string]any{"name": "Front yard"}, &g)
|
||
if g.ID == 0 || g.WidthCM != 1000 || g.HeightCM != 1000 || g.UnitPref != domain.UnitMetric || g.MyRole != domain.RoleOwner {
|
||
t.Errorf("default garden = %+v", g)
|
||
}
|
||
var imperial domain.Garden
|
||
mustCall("create_garden", map[string]any{"name": "Allotment", "widthCm": 609.6, "heightCm": 304.8, "units": "Imperial", "notes": "Zone 6a"}, &imperial)
|
||
if imperial.UnitPref != domain.UnitImperial || imperial.Notes != "Zone 6a" || imperial.WidthCM != 609.6 {
|
||
t.Errorf("imperial garden = %+v", imperial)
|
||
}
|
||
if r := call("create_garden", map[string]any{"name": " "}); !r.IsError {
|
||
t.Error("a garden with a blank name was created")
|
||
}
|
||
|
||
// --- seed lots: record, correct, delete.
|
||
cp := mustPlant(t, svc, owner, "Cherokee Purple", 60, "🍅")
|
||
var lot domain.SeedLot
|
||
mustCall("record_seed_lot", map[string]any{"plantId": cp.ID, "quantity": 2, "unit": "packets", "vendor": "Baker Creek"}, &lot)
|
||
if r := call("update_seed_lot", map[string]any{"lotId": lot.ID, "version": lot.Version}); !r.IsError || !strings.Contains(r.Content, "what to change") {
|
||
t.Errorf("update_seed_lot with nothing to change = %q", r.Content)
|
||
}
|
||
if r := call("update_seed_lot", map[string]any{"lotId": lot.ID, "version": lot.Version, "purchasedAt": "last spring"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
|
||
t.Errorf("a prose purchase date = %q, want a refusal naming the format", r.Content)
|
||
}
|
||
mustCall("update_seed_lot", map[string]any{"lotId": lot.ID, "version": lot.Version, "quantity": 3, "packedForYear": 2026, "purchasedAt": "2026-02-01"}, &lot)
|
||
if lot.Quantity != 3 || lot.Remaining != 3 || lot.Vendor != "Baker Creek" || lot.PackedForYear == nil || *lot.PackedForYear != 2026 || lot.PurchasedAt == nil || *lot.PurchasedAt != "2026-02-01" {
|
||
t.Errorf("corrected lot = %+v; want quantity 3 (all remaining), vendor kept, year and date set", lot)
|
||
}
|
||
|
||
// --- delete_plant: refused while the lot references it, in plain words.
|
||
if r := call("delete_plant", map[string]any{"plantId": cp.ID}); !r.IsError || !strings.Contains(r.Content, "seed lot") {
|
||
t.Errorf("delete_plant with a lot = %q, want a refusal that names the lot", r.Content)
|
||
}
|
||
mustCall("delete_seed_lot", map[string]any{"lotId": lot.ID}, nil)
|
||
var lots []domain.SeedLot
|
||
mustCall("list_seed_lots", map[string]any{"plantId": cp.ID}, &lots)
|
||
if len(lots) != 0 {
|
||
t.Errorf("lots after delete = %+v, want none", lots)
|
||
}
|
||
// ...and while a planting (even a pulled one) references it.
|
||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200})
|
||
if err != nil {
|
||
t.Fatalf("bed: %v", err)
|
||
}
|
||
var plop domain.Planting
|
||
mustCall("place_planting", map[string]any{"objectId": bed.ID, "plantId": cp.ID, "xCm": 0, "yCm": 0}, &plop)
|
||
mustCall("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}, nil)
|
||
if r := call("delete_plant", map[string]any{"plantId": cp.ID}); !r.IsError || !strings.Contains(r.Content, "past seasons") {
|
||
t.Errorf("delete_plant with a pulled planting = %q, want a refusal that says past seasons count", r.Content)
|
||
}
|
||
if err := svc.DeletePlanting(ctx, owner, plop.ID); err != nil {
|
||
t.Fatalf("hard delete: %v", err)
|
||
}
|
||
mustCall("delete_plant", map[string]any{"plantId": cp.ID}, nil)
|
||
var matches []struct{ ID int64 }
|
||
mustCall("find_plant", map[string]any{"query": "Cherokee Purple"}, &matches)
|
||
for _, m := range matches {
|
||
if m.ID == cp.ID {
|
||
t.Error("the deleted plant is still in the catalog")
|
||
}
|
||
}
|
||
// Built-ins are not the user's to delete.
|
||
mustCall("find_plant", map[string]any{"query": "tomato"}, &matches)
|
||
if len(matches) == 0 {
|
||
t.Fatal("no built-in tomato to test with")
|
||
}
|
||
if r := call("delete_plant", map[string]any{"plantId": matches[0].ID}); !r.IsError {
|
||
t.Error("a built-in plant was deleted")
|
||
}
|
||
}
|