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