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
+91 -3
View File
@@ -335,7 +335,7 @@ func TestPartialWorkSurvivesATimeout(t *testing.T) {
if _, conflicts, rerr := svc.RevertChangeSet(ctx, owner, after[0].ID, domain.SourceUI); rerr != nil || len(conflicts) != 0 {
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", rerr, conflicts)
}
o, _ := svc.DescribeGarden(ctx, owner, g.ID)
o, _ := svc.DescribeGarden(ctx, owner, g.ID, nil)
if len(o.Objects) > 0 && o.Objects[0].XCM != bed.XCM {
t.Errorf("undo left the bed at %v, want %v", o.Objects[0].XCM, bed.XCM)
}
@@ -368,7 +368,7 @@ func TestSystemPromptKnowsTheDayAndTheGardenersUnits(t *testing.T) {
"feet and inches",
"24.0 x 24.0 ft",
"never describe a change you did not make",
"You cannot undo",
"undo_change",
"Undo this",
"rather than clearing beds on a guess",
"not to narrate your own planting",
@@ -495,8 +495,96 @@ func TestTurnOnAnotherGardenFilesHistoryThere(t *testing.T) {
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, afterB[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts)
}
d, _ := svc.DescribeGarden(ctx, owner, b.ID)
d, _ := svc.DescribeGarden(ctx, owner, b.ID, nil)
if len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
t.Errorf("after undo B's bed is %+v, want its original name back", d.Objects)
}
}
// TestTurnThatOnlyUndoesIsItselfUndoable — a revert is its own change set,
// outside the turn's scope, so a turn that did nothing but undo would come back
// with no change of its own; the reply would then be the one change in the
// conversation without an "Undo this". It gets the revert instead — a redo.
func TestTurnThatOnlyUndoesIsItselfUndoable(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
beets := mustPlant(t, svc, owner, "Beets", 10, "🫜")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400})
if err != nil {
t.Fatalf("bed: %v", err)
}
// The beets went in by hand in the editor: the change the person wants undone.
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", beets.ID, nil, service.FillClump, nil); err != nil {
t.Fatalf("plant beets: %v", err)
}
history, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil || len(history) == 0 {
t.Fatalf("history: %v (%d entries)", err, len(history))
}
planted := history[0]
r := scriptedRunner(t, svc,
toolCall("read_history", map[string]any{"gardenId": g.ID}),
toolCall("undo_change", map[string]any{"changeSetId": planted.ID}),
fake.Reply("Undone — the beets are out of the bed again."),
)
turn, err := r.Run(ctx, owner, g.ID, "undo the beets", "", nil, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
full, err := svc.GardenFull(ctx, owner, g.ID, nil)
if err != nil {
t.Fatalf("GardenFull: %v", err)
}
if len(full.Plantings) != 0 {
t.Fatalf("%d beets still in the bed after the undo", len(full.Plantings))
}
after, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if len(after) != len(history)+1 {
t.Fatalf("history grew by %d, want exactly the revert", len(after)-len(history))
}
revert := after[0]
if revert.Source != domain.SourceAgent || revert.RevertsID == nil || *revert.RevertsID != planted.ID {
t.Errorf("newest entry = %+v; want the agent's revert of %d", revert, planted.ID)
}
if turn.ChangeSetID == nil || *turn.ChangeSetID != revert.ID {
t.Fatalf("turn.ChangeSetID = %v, want the revert %d so the reply can offer a redo", turn.ChangeSetID, revert.ID)
}
// And "Undo this" on that reply is a redo.
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, *turn.ChangeSetID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("redo: err=%v conflicts=%+v", err, conflicts)
}
full, _ = svc.GardenFull(ctx, owner, g.ID, nil)
if len(full.Plantings) == 0 {
t.Error("redoing the turn did not put the beets back")
}
}
// TestSystemPromptCarriesTheGardenersNotes — the notes are the assistant's
// memory: what the gardener told it about the place comes back on every turn,
// quoted as their words rather than pasted as instructions.
func TestSystemPromptCarriesTheGardenersNotes(t *testing.T) {
with := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric,
Notes: "Zone 6a.\nLast frost \"usually\" May 10."}, "2026-08-23")
for _, want := range []string{
`"Zone 6a.\nLast frost \"usually\" May 10."`,
"update_garden",
"undo_change",
"describe_garden with a year",
} {
if !strings.Contains(with, want) {
t.Errorf("prompt is missing %q", want)
}
}
if strings.Contains(with, "You cannot undo") {
t.Error("the prompt still says the assistant cannot undo")
}
without := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric}, "2026-08-23")
if !strings.Contains(without, "no notes") {
t.Error("a garden without notes doesn't say so")
}
}