Agent: undo for real, past seasons, and tools that correct the record
Six tools the live assistant kept needing and a prompt that knows about them: - undo_change wraps RevertChangeSet(source=agent). A revert is its own change set, so Run reports the last one as the turn's handle when the turn changed nothing else — an undo-only reply keeps its "Undo this", which is now a redo. - describe_garden takes a year: the season view (GardenFull(year)), pulled plops included, with removed/removedAt per group and per plop; list_years says which years have records. Rotation questions finally have data. - update_planting corrects a plop's date, count, label, radius or seed lot in place; remove_planting, remove_plantings and clear_object take a removedAt so a harvest can be backdated. - update_journal_entry / delete_journal_entry correct a note instead of stacking a contradicting one. - update_garden renames/resizes/re-units a garden and rewrites its notes — and the notes now go into the system prompt as the gardener's standing facts, so "remember we're in zone 6a" persists across conversations. describe_garden also reports the garden's notes, version and grid, which the new tools need. Prompt, CLAUDE.md and DESIGN.md updated to match; UI step labels for the new tools. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
@@ -439,7 +440,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
|
||||
t.Errorf("viewer clear = %v, want ErrForbidden", err)
|
||||
}
|
||||
// But a viewer can DescribeGarden (read).
|
||||
if _, err := s.DescribeGarden(ctx, viewer, g.ID); err != nil {
|
||||
if _, err := s.DescribeGarden(ctx, viewer, g.ID, nil); err != nil {
|
||||
t.Errorf("viewer describe = %v, want ok", err)
|
||||
}
|
||||
}
|
||||
@@ -470,7 +471,7 @@ func TestFillScenario(t *testing.T) {
|
||||
fill("nw", basil.ID)
|
||||
fill("south", beans.ID)
|
||||
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID)
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeGarden: %v", err)
|
||||
}
|
||||
@@ -585,7 +586,7 @@ func TestDescribeGardenGroupsByPlant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID)
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeGarden: %v", err)
|
||||
}
|
||||
@@ -825,3 +826,97 @@ func TestFillByRectangleAttributesSeed(t *testing.T) {
|
||||
t.Errorf("overhanging rectangle: %d plops, %v; want some", len(created), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDescribeGardenByYear — "what was in this bed last year?" is the question
|
||||
// rotation advice hangs on, and a describe of what is growing now cannot answer
|
||||
// it. With a year, describe is the season view: every plop whose time in the
|
||||
// ground overlapped the year, pulled ones included, each group saying how many
|
||||
// came out and when.
|
||||
func TestDescribeGardenByYear(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Seasons", WidthCM: 2000, HeightCM: 2000, Notes: "Zone 6a"})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
bed := seedFillBed(t, s, owner, g.ID, 400, 400)
|
||||
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
|
||||
beans := seedNamedPlant(t, s, owner, "Beans", 10)
|
||||
basil := seedNamedPlant(t, s, owner, "Basil", 25)
|
||||
|
||||
plantAndPull := func(plantID int64, x float64, planted, pulled string) {
|
||||
t.Helper()
|
||||
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plantID, XCM: x, YCM: -100, RadiusCM: 20, PlantedAt: &planted})
|
||||
if err != nil {
|
||||
t.Fatalf("plant %d: %v", plantID, err)
|
||||
}
|
||||
if pulled != "" {
|
||||
if _, err := s.RemovePlanting(ctx, owner, pl.ID, pl.Version, &pulled); err != nil {
|
||||
t.Fatalf("pull %d: %v", pl.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
plantAndPull(garlic.ID, -100, "2025-10-15", "2026-07-01") // overwintered: in both years
|
||||
plantAndPull(beans.ID, 0, "2025-05-01", "2025-09-01") // 2025 only
|
||||
plantAndPull(basil.ID, 100, "2026-06-01", "") // growing now
|
||||
|
||||
groupsOf := func(year *int) map[string]DescribeGroup {
|
||||
t.Helper()
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID, year)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeGarden(%v): %v", year, err)
|
||||
}
|
||||
if (year == nil) != (desc.Year == nil) || (year != nil && *desc.Year != *year) {
|
||||
t.Errorf("describe(%v) reports year %v", year, desc.Year)
|
||||
}
|
||||
if desc.Notes != "Zone 6a" || desc.Version != g.Version {
|
||||
t.Errorf("describe carries notes %q version %d; want the garden's (%q, %d)", desc.Notes, desc.Version, "Zone 6a", g.Version)
|
||||
}
|
||||
out := map[string]DescribeGroup{}
|
||||
for _, gr := range desc.Objects[0].Plantings {
|
||||
out[gr.Plant] = gr
|
||||
}
|
||||
return out
|
||||
}
|
||||
names := func(m map[string]DescribeGroup) []string {
|
||||
var out []string
|
||||
for n := range m {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
now := groupsOf(nil)
|
||||
if got := names(now); !reflect.DeepEqual(got, []string{"Basil"}) {
|
||||
t.Errorf("now = %v, want only the basil still growing", got)
|
||||
}
|
||||
if b := now["Basil"]; b.Removed != 0 || b.RemovedAt != "" || b.Each[0].RemovedAt != "" {
|
||||
t.Errorf("a live plop reports a removal: %+v", b)
|
||||
}
|
||||
|
||||
y2025 := 2025
|
||||
last := groupsOf(&y2025)
|
||||
if got := names(last); !reflect.DeepEqual(got, []string{"Beans", "Garlic"}) {
|
||||
t.Errorf("2025 = %v, want the beans and the overwintered garlic", got)
|
||||
}
|
||||
if b := last["Beans"]; b.Removed != 1 || b.RemovedAt != "2025-09-01" || b.PlantedAt != "2025-05-01" {
|
||||
t.Errorf("2025 beans = %+v; want 1 removed on 2025-09-01, planted 2025-05-01", b)
|
||||
}
|
||||
if gl := last["Garlic"]; gl.Removed != 1 || gl.RemovedAt != "2026-07-01" || len(gl.Each) != 1 || gl.Each[0].RemovedAt != "2026-07-01" {
|
||||
t.Errorf("2025 garlic = %+v; want its 2026 removal on the group and the plop", gl)
|
||||
}
|
||||
|
||||
y2026 := 2026
|
||||
this := groupsOf(&y2026)
|
||||
if got := names(this); !reflect.DeepEqual(got, []string{"Basil", "Garlic"}) {
|
||||
t.Errorf("2026 = %v, want the basil and the garlic pulled in July", got)
|
||||
}
|
||||
|
||||
// A typo'd year is refused, not an empty garden.
|
||||
bad := 20026
|
||||
if _, err := s.DescribeGarden(ctx, owner, g.ID, &bad); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("describe(20026) err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user