package agent import ( "context" "encoding/json" "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: "v@example.com", DisplayName: "V", Password: "password123"}) if err != nil { t.Fatalf("register viewer: %v", err) } if _, err := svc.AddShare(ctx, ownerID, g.ID, "v@example.com", 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: "b@example.com", 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: "a@example.com", 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") } // 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].Undo == 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 := a.day(""); d != nil { t.Errorf("no day at all → %q, want nil (the service default)", *d) } if d := a.day(" 2026-01-02 "); d == nil || *d != "2026-01-02" { t.Errorf("an explicit day → %v, want it trimmed", d) } 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) } }