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:
+60
-24
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user