Agent: catalog and garden tools, and a ready date on every describe group
- 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]>
This commit is contained in:
@@ -931,3 +931,100 @@ func TestRecordKeepingTools(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user