Agent: undo for real, past seasons, and tools that correct the record
Build image / build-and-push (push) Successful in 11s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s

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:
2026-08-23 01:50:10 -04:00
co-authored by Claude Fable 5
parent 5317c92683
commit deec7bb917
10 changed files with 801 additions and 69 deletions
+60 -24
View File
@@ -550,13 +550,22 @@ func (s *Service) ClearPlantings(ctx context.Context, actorID, objectID int64, o
}
// DescribeResult is a structured summary of a garden for prompting an agent.
// Version and Notes are here for update_garden: the version is its guard, and
// the notes are the whole text a new note has to be merged into.
type DescribeResult struct {
GardenID int64 `json:"gardenId"`
Name string `json:"name"`
WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"`
UnitPref string `json:"unitPref"`
Objects []DescribeObject `json:"objects"`
GardenID int64 `json:"gardenId"`
Name string `json:"name"`
WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"`
UnitPref string `json:"unitPref"`
GridSizeCM float64 `json:"gridSizeCm"`
Notes string `json:"notes,omitempty"`
Version int64 `json:"version"`
// Year is set on a season view: the plantings are then every plop whose time
// in the ground overlapped that year, pulled ones included, rather than what
// is growing now.
Year *int `json:"year,omitempty"`
Objects []DescribeObject `json:"objects"`
}
// DescribeObject is one object plus its active plantings grouped by plant, for
@@ -605,6 +614,11 @@ type DescribeGroup struct {
// DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt,
// enough to say when the harvest is due.
DaysToMaturity *int `json:"daysToMaturity,omitempty"`
// Removed counts the plops in the group that have been pulled, and RemovedAt
// is when ("first…last" when they differ). Only a season view lists pulled
// plops, so both are absent from a describe of what is growing now.
Removed int `json:"removed,omitempty"`
RemovedAt string `json:"removedAt,omitempty"`
// Each lists the plops individually (id, version, position, location) only
// when the group has at most maxListedPlops of them.
Each []DescribePlanting `json:"each,omitempty"`
@@ -626,14 +640,18 @@ type DescribePlanting struct {
Location string `json:"location"`
RadiusCM float64 `json:"radiusCm"`
PlantedAt string `json:"plantedAt,omitempty"`
// RemovedAt is set on a pulled plop, which only a season view lists.
RemovedAt string `json:"removedAt,omitempty"`
}
// DescribeGarden returns a structured summary — dimensions, objects, and each
// object's active plantings grouped by plant (count, rough location, planting
// date) — for a garden the actor can view. Built on GardenFull so it inherits
// the ACL check.
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
full, err := s.GardenFull(ctx, actorID, gardenID, nil)
// object's plantings grouped by plant (count, rough location, planting date) —
// for a garden the actor can view. year nil describes what is growing now; a
// year is the season view, every plop whose time in the ground overlapped it,
// pulled ones included — what "what was in this bed last year?" needs. Built
// on GardenFull so it inherits the ACL check and the year's bounds.
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64, year *int) (*DescribeResult, error) {
full, err := s.GardenFull(ctx, actorID, gardenID, year)
if err != nil {
return nil, err
}
@@ -648,12 +666,16 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
}
res := &DescribeResult{
GardenID: full.Garden.ID,
Name: full.Garden.Name,
WidthCM: full.Garden.WidthCM,
HeightCM: full.Garden.HeightCM,
UnitPref: full.Garden.UnitPref,
Objects: make([]DescribeObject, 0, len(full.Objects)),
GardenID: full.Garden.ID,
Name: full.Garden.Name,
WidthCM: full.Garden.WidthCM,
HeightCM: full.Garden.HeightCM,
UnitPref: full.Garden.UnitPref,
GridSizeCM: full.Garden.GridSizeCM,
Notes: full.Garden.Notes,
Version: full.Garden.Version,
Year: year,
Objects: make([]DescribeObject, 0, len(full.Objects)),
}
for i := range full.Objects {
o := &full.Objects[i]
@@ -722,9 +744,13 @@ func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID m
PlantID: pid, Plant: plant.Name, Plops: len(members),
Where: summarizeWhere(o, members), PlantedAt: dateRange(members),
DaysToMaturity: plant.DaysToMaturity,
RemovedAt: dateRangeOf(members, func(pl domain.Planting) *string { return pl.RemovedAt }),
}
for _, pl := range members {
g.Plants += effectiveCount(pl)
if pl.RemovedAt != nil {
g.Removed++
}
}
if len(members) <= maxListedPlops {
g.Each = make([]DescribePlanting, 0, len(members))
@@ -746,6 +772,9 @@ func describePlanting(pl domain.Planting, plantName string) DescribePlanting {
if pl.PlantedAt != nil {
d.PlantedAt = *pl.PlantedAt
}
if pl.RemovedAt != nil {
d.RemovedAt = *pl.RemovedAt
}
return d
}
@@ -759,19 +788,26 @@ func effectiveCount(pl domain.Planting) int {
}
// dateRange is the planting date shared by a group's plops, "first…last" when
// they were planted on different days, or "" when none is dated. ISO dates
// order as strings, so min/max need no parsing.
// they were planted on different days, or "" when none is dated.
func dateRange(plops []domain.Planting) string {
return dateRangeOf(plops, func(pl domain.Planting) *string { return pl.PlantedAt })
}
// dateRangeOf summarizes one date field across a group's plops: the one date
// they share, "first…last" when they differ, or "" when none is set. ISO dates
// order as strings, so min/max need no parsing.
func dateRangeOf(plops []domain.Planting, pick func(domain.Planting) *string) string {
first, last := "", ""
for _, pl := range plops {
if pl.PlantedAt == nil || *pl.PlantedAt == "" {
d := pick(pl)
if d == nil || *d == "" {
continue
}
if first == "" || *pl.PlantedAt < first {
first = *pl.PlantedAt
if first == "" || *d < first {
first = *d
}
if *pl.PlantedAt > last {
last = *pl.PlantedAt
if *d > last {
last = *d
}
}
if first == last {
+98 -3
View File
@@ -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)
}
}
+1 -1
View File
@@ -936,7 +936,7 @@ func TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden(t *testing.T) {
if _, conflicts, err := s.RevertChangeSet(ctx, owner, got.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts)
}
if d, err := s.DescribeGarden(ctx, owner, b.ID); err != nil || len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
if d, err := s.DescribeGarden(ctx, owner, b.ID, nil); err != nil || len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
t.Errorf("after undo B is %+v (%v), want the bed's name back", d, err)
}
}