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
+170
View File
@@ -735,3 +735,173 @@ func TestToolsDefaultToTheServiceDayWithoutOne(t *testing.T) {
t.Errorf("an explicit day beats the default: %v", d)
}
}
// TestRecordKeepingTools covers the tools that correct the record rather than
// change the garden — and the one that undoes a change for real. Each answers a
// thing the live assistant could not do: fix a planting date, backdate a
// harvest, correct a journal note, remember the gardener's zone, see last
// season, and undo without pretending.
func TestRecordKeepingTools(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)
}
}
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Home", WidthCM: 1200, HeightCM: 800, Notes: "Zone 6a."})
if err != nil {
t.Fatalf("garden: %v", err)
}
beet := mustPlant(t, svc, owner, "Beet", 10, "🫜")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "South bed", XCM: 600, YCM: 400, WidthCM: 400, HeightCM: 200})
if err != nil {
t.Fatalf("bed: %v", err)
}
// --- update_garden: one field changes, the rest survive, notes merge by hand.
var desc service.DescribeResult
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
if desc.Notes != "Zone 6a." || desc.Version != g.Version {
t.Fatalf("describe carries notes %q v%d, want %q v%d", desc.Notes, desc.Version, "Zone 6a.", g.Version)
}
if r := call("update_garden", map[string]any{"gardenId": g.ID, "version": desc.Version}); !r.IsError || !strings.Contains(r.Content, "what to change") {
t.Errorf("update_garden with nothing to change = %q, want a refusal that says so", r.Content)
}
var updated domain.Garden
mustCall("update_garden", map[string]any{
"gardenId": g.ID, "version": desc.Version, "notes": desc.Notes + "\nLast frost is usually around May 10.",
}, &updated)
if updated.Name != "Home" || updated.WidthCM != 1200 || updated.HeightCM != 800 || updated.UnitPref != domain.UnitMetric {
t.Errorf("a notes-only update changed other fields: %+v", updated)
}
if !strings.HasPrefix(updated.Notes, "Zone 6a.") || !strings.Contains(updated.Notes, "May 10") {
t.Errorf("notes = %q, want the old note kept and the new line added", updated.Notes)
}
if r := call("update_garden", map[string]any{"gardenId": g.ID, "version": desc.Version, "name": "Stale"}); !r.IsError {
t.Error("update_garden with a stale version succeeded")
}
mustCall("update_garden", map[string]any{"gardenId": g.ID, "version": updated.Version, "units": "Imperial"}, &updated)
if updated.UnitPref != domain.UnitImperial {
t.Errorf("units = %q after asking for imperial", updated.UnitPref)
}
// --- update_planting: correct a plop's record without touching its position.
var plop domain.Planting
mustCall("place_planting", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "xCm": 50, "yCm": -30, "radiusCm": 20, "plantedAt": "2026-05-01"}, &plop)
mustCall("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "count": 5, "label": "from the market"}, &plop)
if plop.Count == nil || *plop.Count != 5 || plop.Label == nil || *plop.Label != "from the market" || plop.XCM != 50 {
t.Errorf("after count+label: %+v", plop)
}
// Decoded into a fresh value: a field the response omits must read as
// cleared, not as whatever the previous decode left in the pointer.
cleared, version := domain.Planting{}, plop.Version
mustCall("update_planting", map[string]any{"plantingId": plop.ID, "version": version, "clearCount": true, "plantedAt": "2026-05-20", "label": ""}, &cleared)
plop = cleared
if plop.Count != nil || plop.Label != nil || plop.PlantedAt == nil || *plop.PlantedAt != "2026-05-20" {
t.Errorf("after clearCount/plantedAt/empty label: %+v", plop)
}
if r := call("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "plantedAt": "May 20"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
t.Errorf("a prose date = %q, want a refusal naming the format", r.Content)
}
if r := call("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "count": 3, "clearCount": true}); !r.IsError {
t.Error("count and clearCount together were accepted")
}
// --- remove_planting on the day the gardener said, not today.
mustCall("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "removedAt": "2026-08-01"}, &plop)
if plop.RemovedAt == nil || *plop.RemovedAt != "2026-08-01" {
t.Errorf("removedAt = %v, want the harvest date 2026-08-01", plop.RemovedAt)
}
// --- the season view sees it, the live view does not.
var years struct{ Years []int }
mustCall("list_years", map[string]any{"gardenId": g.ID}, &years)
if len(years.Years) == 0 || years.Years[0] != 2026 {
t.Errorf("years = %v, want 2026 first", years.Years)
}
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
if len(desc.Objects[0].Plantings) != 0 {
t.Errorf("the live describe still lists the pulled beet: %+v", desc.Objects[0].Plantings)
}
mustCall("describe_garden", map[string]any{"gardenId": g.ID, "year": 2026}, &desc)
if desc.Year == nil || *desc.Year != 2026 || len(desc.Objects[0].Plantings) != 1 {
t.Fatalf("2026 describe = year %v, %d groups; want the beet group", desc.Year, len(desc.Objects[0].Plantings))
}
if gr := desc.Objects[0].Plantings[0]; gr.Removed != 1 || gr.RemovedAt != "2026-08-01" || gr.PlantedAt != "2026-05-20" {
t.Errorf("2026 beet group = %+v; want 1 removed 2026-08-01, planted 2026-05-20", gr)
}
// --- undo_change: the removal is the newest history entry; undoing it puts
// the beet back, as a change that is itself in the history and undoable.
var hist struct {
Entries []historyEntry `json:"entries"`
}
mustCall("read_history", map[string]any{"gardenId": g.ID, "limit": 5}, &hist)
if len(hist.Entries) == 0 || !strings.HasPrefix(hist.Entries[0].Summary, "Removed Beet") {
t.Fatalf("history[0] = %+v, want the beet's removal", hist.Entries)
}
removal := hist.Entries[0].ID
if r := call("undo_change", map[string]any{}); !r.IsError || !strings.Contains(r.Content, "read_history") {
t.Errorf("undo_change without an id = %q, want a refusal pointing at read_history", r.Content)
}
var undone undoResult
mustCall("undo_change", map[string]any{"changeSetId": removal}, &undone)
if undone.ChangeSet == nil || undone.UndoneID != removal || len(undone.Conflicts) != 0 || !strings.Contains(undone.Changes, "1 planting updated") {
t.Errorf("undo result = %+v; want a new change set, no conflicts, one planting updated", undone)
}
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
if len(desc.Objects[0].Plantings) != 1 || desc.Objects[0].Plantings[0].Each[0].ID != plop.ID {
t.Errorf("after the undo the beet is not back: %+v", desc.Objects[0].Plantings)
}
mustCall("read_history", map[string]any{"gardenId": g.ID, "limit": 5}, &hist)
if e := hist.Entries[0]; e.ID != *undone.ChangeSet || e.UndoOf == nil || *e.UndoOf != removal || e.Source != domain.SourceAgent {
t.Errorf("history[0] after undo = %+v; want the agent's revert of %d", e, removal)
}
if !hist.Entries[1].Undone {
t.Error("the removal is not marked undone")
}
if got := (&adapter{}).lastRevert(); got != nil {
t.Errorf("a fresh adapter remembers a revert: %v", *got)
}
// --- the journal: correct an entry in place, then delete it.
var entry domain.JournalEntry
mustCall("add_journal_entry", map[string]any{"gardenId": g.ID, "body": "Aphids on the cantaloupe.", "observedAt": "2026-08-20"}, &entry)
if r := call("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version}); !r.IsError {
t.Error("update_journal_entry with nothing to change succeeded")
}
if r := call("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version, "observedAt": "yesterday"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
t.Errorf("a prose date = %q, want a refusal naming the format", r.Content)
}
mustCall("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version, "body": "Aphids on the cucumbers.", "observedAt": "2026-08-19"}, &entry)
if entry.Body != "Aphids on the cucumbers." || entry.ObservedAt != "2026-08-19" {
t.Errorf("corrected entry = %+v", entry)
}
var journal struct {
Entries []domain.JournalEntry `json:"entries"`
}
mustCall("read_journal", map[string]any{"gardenId": g.ID}, &journal)
if len(journal.Entries) != 1 || journal.Entries[0].Body != "Aphids on the cucumbers." {
t.Errorf("journal after the correction = %+v, want the one corrected entry", journal.Entries)
}
mustCall("delete_journal_entry", map[string]any{"entryId": entry.ID}, nil)
mustCall("read_journal", map[string]any{"gardenId": g.ID}, &journal)
if len(journal.Entries) != 0 {
t.Errorf("journal after the delete = %+v, want empty", journal.Entries)
}
}